Phase 20E Build the review and edit stage for the scan preview.
This commit is contained in:
parent
5ee9ffbdd1
commit
f78e1ac8fc
@ -24,6 +24,39 @@ public sealed class OnboardingController(IOnboardingService onboarding) : Contro
|
|||||||
return model is null ? NotFound() : View(model);
|
return model is null ? NotFound() : View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("scan-review")]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> SaveScanReview(ManuscriptScanReviewForm form, string intent = "save")
|
||||||
|
{
|
||||||
|
var readyToImport = string.Equals(intent, "continue", StringComparison.OrdinalIgnoreCase);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var model = await onboarding.SaveScanReviewAsync(form, readyToImport);
|
||||||
|
if (model is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
TempData["OnboardingReviewMessage"] = readyToImport
|
||||||
|
? "Review saved. Next: import into PlotDirector."
|
||||||
|
: "Review choices saved.";
|
||||||
|
return readyToImport
|
||||||
|
? RedirectToAction(nameof(Index))
|
||||||
|
: RedirectToAction(nameof(ScanReview), new { previewId = form.PreviewID });
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(string.Empty, ex.Message);
|
||||||
|
var model = await onboarding.GetScanReviewAsync(form.PreviewID);
|
||||||
|
if (model is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return View("ScanReview", model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("welcome")]
|
[HttpPost("welcome")]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> Welcome()
|
public async Task<IActionResult> Welcome()
|
||||||
|
|||||||
@ -13,6 +13,14 @@ public static class ManuscriptScanStatuses
|
|||||||
public const string Failed = "Failed";
|
public const string Failed = "Failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class ManuscriptScanReviewStatuses
|
||||||
|
{
|
||||||
|
public const string ScanComplete = "ScanComplete";
|
||||||
|
public const string ReviewInProgress = "ReviewInProgress";
|
||||||
|
public const string ReviewComplete = "ReviewComplete";
|
||||||
|
public const string ReadyToImport = "ReadyToImport";
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class ManuscriptScanCommand
|
public sealed class ManuscriptScanCommand
|
||||||
{
|
{
|
||||||
public int UserID { get; init; }
|
public int UserID { get; init; }
|
||||||
@ -98,6 +106,7 @@ public sealed class ManuscriptScanCharacterCandidatePreview
|
|||||||
public sealed class ManuscriptScanState
|
public sealed class ManuscriptScanState
|
||||||
{
|
{
|
||||||
public string Status { get; init; } = ManuscriptScanStatuses.NotStarted;
|
public string Status { get; init; } = ManuscriptScanStatuses.NotStarted;
|
||||||
|
public string ReviewStatus { get; init; } = ManuscriptScanReviewStatuses.ScanComplete;
|
||||||
public string Message { get; init; } = string.Empty;
|
public string Message { get; init; } = string.Empty;
|
||||||
public int? PercentComplete { get; init; }
|
public int? PercentComplete { get; init; }
|
||||||
public int ChapterCount { get; init; }
|
public int ChapterCount { get; init; }
|
||||||
@ -107,3 +116,40 @@ public sealed class ManuscriptScanState
|
|||||||
public Guid? PreviewID { get; init; }
|
public Guid? PreviewID { get; init; }
|
||||||
public DateTime? UpdatedUtc { get; init; }
|
public DateTime? UpdatedUtc { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ManuscriptScanReviewDecision
|
||||||
|
{
|
||||||
|
public Guid PreviewID { get; init; }
|
||||||
|
public string Status { get; init; } = ManuscriptScanReviewStatuses.ScanComplete;
|
||||||
|
public DateTime UpdatedUtc { get; init; } = DateTime.UtcNow;
|
||||||
|
public IReadOnlyList<ManuscriptScanChapterReviewDecision> Chapters { get; init; } = [];
|
||||||
|
public IReadOnlyList<ManuscriptScanSceneReviewDecision> Scenes { get; init; } = [];
|
||||||
|
public IReadOnlyList<ManuscriptScanCharacterReviewDecision> Characters { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ManuscriptScanChapterReviewDecision
|
||||||
|
{
|
||||||
|
public string TemporaryChapterKey { get; init; } = string.Empty;
|
||||||
|
public bool Include { get; init; }
|
||||||
|
public string Title { get; init; } = string.Empty;
|
||||||
|
public int ChapterNumber { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ManuscriptScanSceneReviewDecision
|
||||||
|
{
|
||||||
|
public string TemporarySceneKey { get; init; } = string.Empty;
|
||||||
|
public string TemporaryChapterKey { get; init; } = string.Empty;
|
||||||
|
public bool Include { get; init; }
|
||||||
|
public string? Title { get; init; }
|
||||||
|
public int SceneNumberWithinChapter { get; init; }
|
||||||
|
public bool AllowZeroWords { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ManuscriptScanCharacterReviewDecision
|
||||||
|
{
|
||||||
|
public string TemporaryCharacterKey { get; init; } = string.Empty;
|
||||||
|
public bool Include { get; init; }
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
public string Category { get; init; } = "PossibleCharacter";
|
||||||
|
public int? ExistingCharacterID { get; init; }
|
||||||
|
}
|
||||||
|
|||||||
@ -13,12 +13,15 @@ public interface IManuscriptScanPreviewStore
|
|||||||
Task<ManuscriptScanState> GetStateAsync(int userId, int onboardingId);
|
Task<ManuscriptScanState> GetStateAsync(int userId, int onboardingId);
|
||||||
Task<ManuscriptScanPreview?> GetLatestPreviewAsync(int userId, int onboardingId);
|
Task<ManuscriptScanPreview?> GetLatestPreviewAsync(int userId, int onboardingId);
|
||||||
Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId);
|
Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId);
|
||||||
|
Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId);
|
||||||
|
Task<ManuscriptScanReviewDecision?> SaveReviewAsync(int userId, ManuscriptScanReviewDecision review);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
|
public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
|
||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<(int UserId, int OnboardingId), ScanSession> sessions = new();
|
private readonly ConcurrentDictionary<(int UserId, int OnboardingId), ScanSession> sessions = new();
|
||||||
private readonly ConcurrentDictionary<Guid, ManuscriptScanPreview> previews = new();
|
private readonly ConcurrentDictionary<Guid, ManuscriptScanPreview> previews = new();
|
||||||
|
private readonly ConcurrentDictionary<Guid, ManuscriptScanReviewDecision> reviews = new();
|
||||||
|
|
||||||
public Task<ManuscriptScanState> StartAsync(int userId, int onboardingId, int projectId, int bookId)
|
public Task<ManuscriptScanState> StartAsync(int userId, int onboardingId, int projectId, int bookId)
|
||||||
{
|
{
|
||||||
@ -71,6 +74,7 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
|
|||||||
});
|
});
|
||||||
|
|
||||||
session.Status = ManuscriptScanStatuses.Complete;
|
session.Status = ManuscriptScanStatuses.Complete;
|
||||||
|
session.ReviewStatus = ManuscriptScanReviewStatuses.ScanComplete;
|
||||||
session.Message = "Scan complete";
|
session.Message = "Scan complete";
|
||||||
session.PercentComplete = 100;
|
session.PercentComplete = 100;
|
||||||
session.ChapterCount = stored.ChapterCount;
|
session.ChapterCount = stored.ChapterCount;
|
||||||
@ -132,10 +136,48 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
|
|||||||
public Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId)
|
public Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId)
|
||||||
=> Task.FromResult(previews.TryGetValue(previewId, out var preview) && preview.UserID == userId ? preview : null);
|
=> Task.FromResult(previews.TryGetValue(previewId, out var preview) && preview.UserID == userId ? preview : null);
|
||||||
|
|
||||||
|
public Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId)
|
||||||
|
{
|
||||||
|
if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId)
|
||||||
|
{
|
||||||
|
return Task.FromResult<ManuscriptScanReviewDecision?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var review = reviews.GetOrAdd(previewId, _ => BuildDefaultReview(preview));
|
||||||
|
return Task.FromResult<ManuscriptScanReviewDecision?>(review);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<ManuscriptScanReviewDecision?> SaveReviewAsync(int userId, ManuscriptScanReviewDecision review)
|
||||||
|
{
|
||||||
|
if (!previews.TryGetValue(review.PreviewID, out var preview) || preview.UserID != userId)
|
||||||
|
{
|
||||||
|
return Task.FromResult<ManuscriptScanReviewDecision?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var stored = new ManuscriptScanReviewDecision
|
||||||
|
{
|
||||||
|
PreviewID = review.PreviewID,
|
||||||
|
Status = review.Status,
|
||||||
|
UpdatedUtc = DateTime.UtcNow,
|
||||||
|
Chapters = review.Chapters,
|
||||||
|
Scenes = review.Scenes,
|
||||||
|
Characters = review.Characters
|
||||||
|
};
|
||||||
|
reviews[review.PreviewID] = stored;
|
||||||
|
if (sessions.TryGetValue((userId, preview.OnboardingID), out var session))
|
||||||
|
{
|
||||||
|
session.ReviewStatus = stored.Status;
|
||||||
|
session.UpdatedUtc = stored.UpdatedUtc;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult<ManuscriptScanReviewDecision?>(stored);
|
||||||
|
}
|
||||||
|
|
||||||
private static ManuscriptScanState ToState(ScanSession session)
|
private static ManuscriptScanState ToState(ScanSession session)
|
||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
Status = session.Status,
|
Status = session.Status,
|
||||||
|
ReviewStatus = session.ReviewStatus,
|
||||||
Message = session.Message,
|
Message = session.Message,
|
||||||
PercentComplete = session.PercentComplete,
|
PercentComplete = session.PercentComplete,
|
||||||
ChapterCount = session.ChapterCount,
|
ChapterCount = session.ChapterCount,
|
||||||
@ -152,6 +194,44 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
|
|||||||
return string.IsNullOrWhiteSpace(cleaned) ? fallback : cleaned;
|
return string.IsNullOrWhiteSpace(cleaned) ? fallback : cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static ManuscriptScanReviewDecision BuildDefaultReview(ManuscriptScanPreview preview)
|
||||||
|
=> new()
|
||||||
|
{
|
||||||
|
PreviewID = preview.PreviewID,
|
||||||
|
Status = ManuscriptScanReviewStatuses.ScanComplete,
|
||||||
|
Chapters = preview.Chapters
|
||||||
|
.OrderBy(chapter => chapter.ChapterNumber)
|
||||||
|
.Select(chapter => new ManuscriptScanChapterReviewDecision
|
||||||
|
{
|
||||||
|
TemporaryChapterKey = chapter.TemporaryChapterKey,
|
||||||
|
Include = !string.IsNullOrWhiteSpace(chapter.Title) && chapter.WordCount > 0,
|
||||||
|
Title = chapter.Title,
|
||||||
|
ChapterNumber = chapter.ChapterNumber
|
||||||
|
})
|
||||||
|
.ToList(),
|
||||||
|
Scenes = preview.Scenes
|
||||||
|
.OrderBy(scene => scene.TemporaryChapterKey)
|
||||||
|
.ThenBy(scene => scene.SceneNumberWithinChapter)
|
||||||
|
.Select(scene => new ManuscriptScanSceneReviewDecision
|
||||||
|
{
|
||||||
|
TemporarySceneKey = scene.TemporarySceneKey,
|
||||||
|
TemporaryChapterKey = scene.TemporaryChapterKey,
|
||||||
|
Include = true,
|
||||||
|
Title = string.IsNullOrWhiteSpace(scene.Title) ? $"Scene {scene.SceneNumberWithinChapter}" : scene.Title,
|
||||||
|
SceneNumberWithinChapter = scene.SceneNumberWithinChapter
|
||||||
|
})
|
||||||
|
.ToList(),
|
||||||
|
Characters = preview.CharacterCandidates
|
||||||
|
.Select(candidate => new ManuscriptScanCharacterReviewDecision
|
||||||
|
{
|
||||||
|
TemporaryCharacterKey = candidate.TemporaryCharacterKey,
|
||||||
|
Include = string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase),
|
||||||
|
Name = candidate.Name,
|
||||||
|
Category = candidate.Category
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
|
|
||||||
private sealed class ScanSession
|
private sealed class ScanSession
|
||||||
{
|
{
|
||||||
public int UserID { get; init; }
|
public int UserID { get; init; }
|
||||||
@ -159,6 +239,7 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
|
|||||||
public int ProjectID { get; init; }
|
public int ProjectID { get; init; }
|
||||||
public int BookID { get; init; }
|
public int BookID { get; init; }
|
||||||
public string Status { get; set; } = ManuscriptScanStatuses.NotStarted;
|
public string Status { get; set; } = ManuscriptScanStatuses.NotStarted;
|
||||||
|
public string ReviewStatus { get; set; } = ManuscriptScanReviewStatuses.ScanComplete;
|
||||||
public string Message { get; set; } = string.Empty;
|
public string Message { get; set; } = string.Empty;
|
||||||
public int? PercentComplete { get; set; }
|
public int? PercentComplete { get; set; }
|
||||||
public int ChapterCount { get; set; }
|
public int ChapterCount { get; set; }
|
||||||
|
|||||||
@ -9,6 +9,7 @@ public interface IOnboardingService
|
|||||||
Task<UserOnboardingState> GetOrCreateAsync();
|
Task<UserOnboardingState> GetOrCreateAsync();
|
||||||
Task<OnboardingWizardViewModel> GetWizardAsync();
|
Task<OnboardingWizardViewModel> GetWizardAsync();
|
||||||
Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null);
|
Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null);
|
||||||
|
Task<ManuscriptScanReviewViewModel?> SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport);
|
||||||
Task<bool> ShouldShowOnboardingAsync();
|
Task<bool> ShouldShowOnboardingAsync();
|
||||||
Task<OnboardingNudgeViewModel> GetDashboardNudgeAsync();
|
Task<OnboardingNudgeViewModel> GetDashboardNudgeAsync();
|
||||||
Task<bool> ShouldShowWordCompanionHeaderAsync();
|
Task<bool> ShouldShowWordCompanionHeaderAsync();
|
||||||
@ -29,6 +30,7 @@ public sealed class OnboardingService(
|
|||||||
IProjectActivityService activity,
|
IProjectActivityService activity,
|
||||||
IWordCompanionPresenceService companionPresence,
|
IWordCompanionPresenceService companionPresence,
|
||||||
IManuscriptScanPreviewStore scanPreviews,
|
IManuscriptScanPreviewStore scanPreviews,
|
||||||
|
ICharacterRepository characters,
|
||||||
ICurrentUserService currentUser) : IOnboardingService
|
ICurrentUserService currentUser) : IOnboardingService
|
||||||
{
|
{
|
||||||
private static readonly HashSet<string> WritingJourneys = new(StringComparer.Ordinal)
|
private static readonly HashSet<string> WritingJourneys = new(StringComparer.Ordinal)
|
||||||
@ -102,6 +104,87 @@ public sealed class OnboardingService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null)
|
public async Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null)
|
||||||
|
{
|
||||||
|
var context = await GetScanReviewContextAsync(previewId);
|
||||||
|
return context is null ? null : BuildScanReviewViewModel(context.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ManuscriptScanReviewViewModel?> SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport)
|
||||||
|
{
|
||||||
|
var context = await GetScanReviewContextAsync(form.PreviewID);
|
||||||
|
if (context is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (userId, preview, _, project, book, existingCharacters) = context.Value;
|
||||||
|
var chapterLookup = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
|
||||||
|
var sceneLookup = preview.Scenes.ToDictionary(scene => scene.TemporarySceneKey, StringComparer.Ordinal);
|
||||||
|
var candidateLookup = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
|
||||||
|
var existingCharacterIds = existingCharacters.Select(character => character.CharacterID).ToHashSet();
|
||||||
|
|
||||||
|
var chapterDecisions = form.Chapters
|
||||||
|
.Where(item => chapterLookup.ContainsKey(item.TemporaryChapterKey))
|
||||||
|
.Select(item => new ManuscriptScanChapterReviewDecision
|
||||||
|
{
|
||||||
|
TemporaryChapterKey = item.TemporaryChapterKey,
|
||||||
|
Include = item.Include,
|
||||||
|
Title = Clean(item.Title),
|
||||||
|
ChapterNumber = item.ChapterNumber
|
||||||
|
})
|
||||||
|
.OrderBy(item => item.ChapterNumber)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var includedChapterKeys = chapterDecisions.Where(item => item.Include).Select(item => item.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
|
||||||
|
var sceneDecisions = form.Scenes
|
||||||
|
.Where(item => sceneLookup.ContainsKey(item.TemporarySceneKey))
|
||||||
|
.Select(item => new ManuscriptScanSceneReviewDecision
|
||||||
|
{
|
||||||
|
TemporarySceneKey = item.TemporarySceneKey,
|
||||||
|
TemporaryChapterKey = item.TemporaryChapterKey,
|
||||||
|
Include = item.Include && includedChapterKeys.Contains(item.TemporaryChapterKey),
|
||||||
|
Title = CleanOptional(item.Title),
|
||||||
|
SceneNumberWithinChapter = item.SceneNumberWithinChapter,
|
||||||
|
AllowZeroWords = item.AllowZeroWords
|
||||||
|
})
|
||||||
|
.OrderBy(item => item.TemporaryChapterKey)
|
||||||
|
.ThenBy(item => item.SceneNumberWithinChapter)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var characterDecisions = form.Characters
|
||||||
|
.Where(item => candidateLookup.ContainsKey(item.TemporaryCharacterKey))
|
||||||
|
.Select(item =>
|
||||||
|
{
|
||||||
|
var cleanName = Clean(item.Name);
|
||||||
|
var matchedExistingId = item.ExistingCharacterID.HasValue && existingCharacterIds.Contains(item.ExistingCharacterID.Value)
|
||||||
|
? item.ExistingCharacterID
|
||||||
|
: MatchExistingCharacter(cleanName, existingCharacters)?.CharacterID;
|
||||||
|
return new ManuscriptScanCharacterReviewDecision
|
||||||
|
{
|
||||||
|
TemporaryCharacterKey = item.TemporaryCharacterKey,
|
||||||
|
Include = item.Include,
|
||||||
|
Name = cleanName,
|
||||||
|
Category = item.Category,
|
||||||
|
ExistingCharacterID = matchedExistingId
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var review = new ManuscriptScanReviewDecision
|
||||||
|
{
|
||||||
|
PreviewID = preview.PreviewID,
|
||||||
|
Status = readyToImport ? ManuscriptScanReviewStatuses.ReadyToImport : ManuscriptScanReviewStatuses.ReviewInProgress,
|
||||||
|
Chapters = chapterDecisions,
|
||||||
|
Scenes = sceneDecisions,
|
||||||
|
Characters = characterDecisions
|
||||||
|
};
|
||||||
|
|
||||||
|
ValidateScanReview(review, preview);
|
||||||
|
var saved = await scanPreviews.SaveReviewAsync(userId, review);
|
||||||
|
return saved is null ? null : BuildScanReviewViewModel((userId, preview, saved, project, book, existingCharacters));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList<Character> ExistingCharacters)?> GetScanReviewContextAsync(Guid? previewId)
|
||||||
{
|
{
|
||||||
var userId = RequireUserId();
|
var userId = RequireUserId();
|
||||||
var state = await onboarding.GetAsync(userId);
|
var state = await onboarding.GetAsync(userId);
|
||||||
@ -123,14 +206,111 @@ public sealed class OnboardingService(
|
|||||||
|
|
||||||
var project = await projects.GetForUserAsync(preview.ProjectID, userId);
|
var project = await projects.GetForUserAsync(preview.ProjectID, userId);
|
||||||
var book = await books.GetAsync(preview.BookID);
|
var book = await books.GetAsync(preview.BookID);
|
||||||
if (project is null || book is null || book.ProjectID != project.ProjectID)
|
if (project is null || !project.IsOwner || project.IsArchived || book is null || book.ProjectID != project.ProjectID || book.IsArchived)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var review = await scanPreviews.GetReviewAsync(userId, preview.PreviewID);
|
||||||
|
if (review is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingCharacters = await characters.ListCharactersAsync(project.ProjectID);
|
||||||
|
return (userId, preview, review, project, book, existingCharacters.Where(character => !character.IsArchived).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ManuscriptScanReviewViewModel BuildScanReviewViewModel(
|
||||||
|
(int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList<Character> ExistingCharacters) context)
|
||||||
|
{
|
||||||
|
var (userId, preview, review, project, book, existingCharacters) = context;
|
||||||
|
_ = userId;
|
||||||
|
var chapterReview = review.Chapters.ToDictionary(item => item.TemporaryChapterKey, StringComparer.Ordinal);
|
||||||
|
var sceneReview = review.Scenes.ToDictionary(item => item.TemporarySceneKey, StringComparer.Ordinal);
|
||||||
|
var characterReview = review.Characters.ToDictionary(item => item.TemporaryCharacterKey, StringComparer.Ordinal);
|
||||||
|
var existingOptions = existingCharacters
|
||||||
|
.OrderBy(character => character.CharacterName)
|
||||||
|
.Select(character => new ManuscriptScanExistingCharacterOptionViewModel
|
||||||
|
{
|
||||||
|
CharacterID = character.CharacterID,
|
||||||
|
CharacterName = character.CharacterName
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var chapters = preview.Chapters
|
||||||
|
.OrderBy(chapter => chapter.ChapterNumber)
|
||||||
|
.Select(chapter =>
|
||||||
|
{
|
||||||
|
chapterReview.TryGetValue(chapter.TemporaryChapterKey, out var decision);
|
||||||
|
var scenes = preview.Scenes
|
||||||
|
.Where(scene => string.Equals(scene.TemporaryChapterKey, chapter.TemporaryChapterKey, StringComparison.Ordinal))
|
||||||
|
.OrderBy(scene => scene.SceneNumberWithinChapter)
|
||||||
|
.Select(scene =>
|
||||||
|
{
|
||||||
|
sceneReview.TryGetValue(scene.TemporarySceneKey, out var sceneDecision);
|
||||||
|
return new ManuscriptScanReviewSceneViewModel
|
||||||
|
{
|
||||||
|
TemporarySceneKey = scene.TemporarySceneKey,
|
||||||
|
TemporaryChapterKey = scene.TemporaryChapterKey,
|
||||||
|
SceneNumberWithinChapter = scene.SceneNumberWithinChapter,
|
||||||
|
Title = scene.Title,
|
||||||
|
ReviewTitle = sceneDecision?.Title ?? (string.IsNullOrWhiteSpace(scene.Title) ? $"Scene {scene.SceneNumberWithinChapter}" : scene.Title),
|
||||||
|
Include = sceneDecision?.Include ?? true,
|
||||||
|
WordCount = scene.WordCount,
|
||||||
|
OpeningTextPreview = scene.OpeningTextPreview
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new ManuscriptScanReviewChapterViewModel
|
||||||
|
{
|
||||||
|
TemporaryChapterKey = chapter.TemporaryChapterKey,
|
||||||
|
ChapterNumber = chapter.ChapterNumber,
|
||||||
|
Title = chapter.Title,
|
||||||
|
ReviewTitle = decision?.Title ?? chapter.Title,
|
||||||
|
Include = decision?.Include ?? (!string.IsNullOrWhiteSpace(chapter.Title) && chapter.WordCount > 0),
|
||||||
|
WordCount = chapter.WordCount,
|
||||||
|
Scenes = scenes
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var characters = preview.CharacterCandidates
|
||||||
|
.OrderByDescending(candidate => CandidateCategorySortValue(candidate.Category))
|
||||||
|
.ThenByDescending(candidate => candidate.QualityScore)
|
||||||
|
.ThenByDescending(candidate => candidate.MentionCount)
|
||||||
|
.ThenBy(candidate => candidate.Name)
|
||||||
|
.Select(candidate =>
|
||||||
|
{
|
||||||
|
characterReview.TryGetValue(candidate.TemporaryCharacterKey, out var decision);
|
||||||
|
var reviewName = decision?.Name ?? candidate.Name;
|
||||||
|
var existing = decision?.ExistingCharacterID.HasValue == true
|
||||||
|
? existingCharacters.FirstOrDefault(character => character.CharacterID == decision.ExistingCharacterID.Value)
|
||||||
|
: MatchExistingCharacter(reviewName, existingCharacters);
|
||||||
|
return new ManuscriptScanReviewCharacterViewModel
|
||||||
|
{
|
||||||
|
TemporaryCharacterKey = candidate.TemporaryCharacterKey,
|
||||||
|
Name = candidate.Name,
|
||||||
|
ReviewName = reviewName,
|
||||||
|
Include = decision?.Include ?? string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase),
|
||||||
|
MentionCount = candidate.MentionCount,
|
||||||
|
QualityScore = candidate.QualityScore,
|
||||||
|
Category = candidate.Category,
|
||||||
|
Reason = candidate.Reason,
|
||||||
|
ExistingCharacterID = existing?.CharacterID,
|
||||||
|
ExistingCharacterName = existing?.CharacterName
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var selectedChapterKeys = chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
|
||||||
|
var selectedScenes = chapters.SelectMany(chapter => chapter.Scenes).Where(scene => scene.Include && selectedChapterKeys.Contains(scene.TemporaryChapterKey)).ToList();
|
||||||
|
|
||||||
return new ManuscriptScanReviewViewModel
|
return new ManuscriptScanReviewViewModel
|
||||||
{
|
{
|
||||||
PreviewID = preview.PreviewID,
|
PreviewID = preview.PreviewID,
|
||||||
|
ReviewStatus = review.Status,
|
||||||
DocumentTitle = string.IsNullOrWhiteSpace(preview.DocumentTitle) ? "Word manuscript" : preview.DocumentTitle!,
|
DocumentTitle = string.IsNullOrWhiteSpace(preview.DocumentTitle) ? "Word manuscript" : preview.DocumentTitle!,
|
||||||
SelectedProjectName = project.ProjectName,
|
SelectedProjectName = project.ProjectName,
|
||||||
SelectedBookTitle = BookOptionTitle(book),
|
SelectedBookTitle = BookOptionTitle(book),
|
||||||
@ -138,29 +318,83 @@ public sealed class OnboardingService(
|
|||||||
ChapterCount = preview.ChapterCount,
|
ChapterCount = preview.ChapterCount,
|
||||||
SceneCount = preview.SceneCount,
|
SceneCount = preview.SceneCount,
|
||||||
CharacterCandidateCount = preview.CharacterCandidateCount,
|
CharacterCandidateCount = preview.CharacterCandidateCount,
|
||||||
Chapters = preview.Chapters
|
SelectedChapterCount = selectedChapterKeys.Count,
|
||||||
.OrderBy(chapter => chapter.ChapterNumber)
|
SelectedSceneCount = selectedScenes.Count,
|
||||||
.Select(chapter => new ManuscriptScanReviewChapterViewModel
|
SelectedCharacterCount = characters.Count(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)),
|
||||||
{
|
SelectedWordCount = selectedScenes.Sum(scene => scene.WordCount),
|
||||||
TemporaryChapterKey = chapter.TemporaryChapterKey,
|
Chapters = chapters,
|
||||||
ChapterNumber = chapter.ChapterNumber,
|
CharacterCandidates = characters,
|
||||||
Title = chapter.Title,
|
ExistingCharacterOptions = existingOptions
|
||||||
WordCount = chapter.WordCount,
|
|
||||||
Scenes = preview.Scenes
|
|
||||||
.Where(scene => string.Equals(scene.TemporaryChapterKey, chapter.TemporaryChapterKey, StringComparison.Ordinal))
|
|
||||||
.OrderBy(scene => scene.SceneNumberWithinChapter)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.ToList(),
|
|
||||||
CharacterCandidates = preview.CharacterCandidates
|
|
||||||
.OrderByDescending(candidate => CandidateCategorySortValue(candidate.Category))
|
|
||||||
.ThenByDescending(candidate => candidate.QualityScore)
|
|
||||||
.ThenByDescending(candidate => candidate.MentionCount)
|
|
||||||
.ThenBy(candidate => candidate.Name)
|
|
||||||
.ToList()
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ValidateScanReview(ManuscriptScanReviewDecision review, ManuscriptScanPreview preview)
|
||||||
|
{
|
||||||
|
var previewChapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
|
||||||
|
var previewScenes = preview.Scenes.ToDictionary(scene => scene.TemporarySceneKey, StringComparer.Ordinal);
|
||||||
|
var includedChapterKeys = review.Chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
if (review.Chapters.Where(chapter => chapter.Include).Any(chapter => string.IsNullOrWhiteSpace(chapter.Title)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Every included chapter needs a title.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (review.Chapters.Select(chapter => chapter.ChapterNumber).Distinct().Count() != review.Chapters.Count)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Chapter order is not valid. Restore the scan defaults and try again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var scene in review.Scenes.Where(scene => scene.Include))
|
||||||
|
{
|
||||||
|
if (!includedChapterKeys.Contains(scene.TemporaryChapterKey))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Included scenes must belong to included chapters.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!previewScenes.TryGetValue(scene.TemporarySceneKey, out var previewScene))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("One of the selected scenes no longer exists in the scan preview.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewScene.WordCount <= 0 && !scene.AllowZeroWords)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Included scenes with no words must be explicitly allowed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var chapter in review.Chapters)
|
||||||
|
{
|
||||||
|
if (!previewChapters.ContainsKey(chapter.TemporaryChapterKey))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("One of the selected chapters no longer exists in the scan preview.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var duplicateName = review.Characters
|
||||||
|
.Where(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Select(character => Clean(character.Name))
|
||||||
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
|
.GroupBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.FirstOrDefault(group => group.Count() > 1);
|
||||||
|
if (duplicateName is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"The character name \"{duplicateName.Key}\" appears more than once. Exclude or rename one before continuing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (review.Characters.Any(character => character.Include && string.IsNullOrWhiteSpace(character.Name)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Every included character needs a display name.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Character? MatchExistingCharacter(string? name, IReadOnlyList<Character> existingCharacters)
|
||||||
|
{
|
||||||
|
var clean = Clean(name);
|
||||||
|
return string.IsNullOrWhiteSpace(clean)
|
||||||
|
? null
|
||||||
|
: existingCharacters.FirstOrDefault(character => string.Equals(character.CharacterName, clean, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> ShouldShowOnboardingAsync()
|
public async Task<bool> ShouldShowOnboardingAsync()
|
||||||
{
|
{
|
||||||
if (!currentUser.UserId.HasValue)
|
if (!currentUser.UserId.HasValue)
|
||||||
@ -500,6 +734,7 @@ public sealed class OnboardingService(
|
|||||||
=> new()
|
=> new()
|
||||||
{
|
{
|
||||||
Status = state.Status,
|
Status = state.Status,
|
||||||
|
ReviewStatus = state.ReviewStatus,
|
||||||
Message = state.Message,
|
Message = state.Message,
|
||||||
PercentComplete = state.PercentComplete,
|
PercentComplete = state.PercentComplete,
|
||||||
ChapterCount = state.ChapterCount,
|
ChapterCount = state.ChapterCount,
|
||||||
|
|||||||
@ -117,6 +117,7 @@ public sealed class CompanionPresenceViewModel
|
|||||||
public sealed class ManuscriptScanStateViewModel
|
public sealed class ManuscriptScanStateViewModel
|
||||||
{
|
{
|
||||||
public string Status { get; init; } = ManuscriptScanStatuses.NotStarted;
|
public string Status { get; init; } = ManuscriptScanStatuses.NotStarted;
|
||||||
|
public string ReviewStatus { get; init; } = ManuscriptScanReviewStatuses.ScanComplete;
|
||||||
public string Message { get; init; } = string.Empty;
|
public string Message { get; init; } = string.Empty;
|
||||||
public int? PercentComplete { get; init; }
|
public int? PercentComplete { get; init; }
|
||||||
public int ChapterCount { get; init; }
|
public int ChapterCount { get; init; }
|
||||||
@ -132,6 +133,7 @@ public sealed class ManuscriptScanStateViewModel
|
|||||||
public sealed class ManuscriptScanReviewViewModel
|
public sealed class ManuscriptScanReviewViewModel
|
||||||
{
|
{
|
||||||
public Guid PreviewID { get; init; }
|
public Guid PreviewID { get; init; }
|
||||||
|
public string ReviewStatus { get; init; } = ManuscriptScanReviewStatuses.ScanComplete;
|
||||||
public string DocumentTitle { get; init; } = "Word manuscript";
|
public string DocumentTitle { get; init; } = "Word manuscript";
|
||||||
public string SelectedProjectName { get; init; } = string.Empty;
|
public string SelectedProjectName { get; init; } = string.Empty;
|
||||||
public string SelectedBookTitle { get; init; } = string.Empty;
|
public string SelectedBookTitle { get; init; } = string.Empty;
|
||||||
@ -139,8 +141,13 @@ public sealed class ManuscriptScanReviewViewModel
|
|||||||
public int ChapterCount { get; init; }
|
public int ChapterCount { get; init; }
|
||||||
public int SceneCount { get; init; }
|
public int SceneCount { get; init; }
|
||||||
public int CharacterCandidateCount { get; init; }
|
public int CharacterCandidateCount { get; init; }
|
||||||
|
public int SelectedChapterCount { get; init; }
|
||||||
|
public int SelectedSceneCount { get; init; }
|
||||||
|
public int SelectedCharacterCount { get; init; }
|
||||||
|
public int SelectedWordCount { get; init; }
|
||||||
public IReadOnlyList<ManuscriptScanReviewChapterViewModel> Chapters { get; init; } = [];
|
public IReadOnlyList<ManuscriptScanReviewChapterViewModel> Chapters { get; init; } = [];
|
||||||
public IReadOnlyList<ManuscriptScanCharacterCandidatePreview> CharacterCandidates { get; init; } = [];
|
public IReadOnlyList<ManuscriptScanReviewCharacterViewModel> CharacterCandidates { get; init; } = [];
|
||||||
|
public IReadOnlyList<ManuscriptScanExistingCharacterOptionViewModel> ExistingCharacterOptions { get; init; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ManuscriptScanReviewChapterViewModel
|
public sealed class ManuscriptScanReviewChapterViewModel
|
||||||
@ -148,6 +155,76 @@ public sealed class ManuscriptScanReviewChapterViewModel
|
|||||||
public string TemporaryChapterKey { get; init; } = string.Empty;
|
public string TemporaryChapterKey { get; init; } = string.Empty;
|
||||||
public int ChapterNumber { get; init; }
|
public int ChapterNumber { get; init; }
|
||||||
public string Title { get; init; } = string.Empty;
|
public string Title { get; init; } = string.Empty;
|
||||||
|
public string ReviewTitle { get; init; } = string.Empty;
|
||||||
|
public bool Include { get; init; }
|
||||||
public int WordCount { get; init; }
|
public int WordCount { get; init; }
|
||||||
public IReadOnlyList<ManuscriptScanScenePreview> Scenes { get; init; } = [];
|
public IReadOnlyList<ManuscriptScanReviewSceneViewModel> Scenes { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ManuscriptScanReviewSceneViewModel
|
||||||
|
{
|
||||||
|
public string TemporarySceneKey { get; init; } = string.Empty;
|
||||||
|
public string TemporaryChapterKey { get; init; } = string.Empty;
|
||||||
|
public int SceneNumberWithinChapter { get; init; }
|
||||||
|
public string? Title { get; init; }
|
||||||
|
public string? ReviewTitle { get; init; }
|
||||||
|
public bool Include { get; init; }
|
||||||
|
public int WordCount { get; init; }
|
||||||
|
public string? OpeningTextPreview { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ManuscriptScanReviewCharacterViewModel
|
||||||
|
{
|
||||||
|
public string TemporaryCharacterKey { get; init; } = string.Empty;
|
||||||
|
public string Name { get; init; } = string.Empty;
|
||||||
|
public string ReviewName { get; init; } = string.Empty;
|
||||||
|
public bool Include { get; init; }
|
||||||
|
public int MentionCount { get; init; }
|
||||||
|
public int QualityScore { get; init; }
|
||||||
|
public string Category { get; init; } = "PossibleCharacter";
|
||||||
|
public string? Reason { get; init; }
|
||||||
|
public int? ExistingCharacterID { get; init; }
|
||||||
|
public string? ExistingCharacterName { get; init; }
|
||||||
|
public bool IsExistingCharacterMatch => ExistingCharacterID.HasValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ManuscriptScanExistingCharacterOptionViewModel
|
||||||
|
{
|
||||||
|
public int CharacterID { get; init; }
|
||||||
|
public string CharacterName { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ManuscriptScanReviewForm
|
||||||
|
{
|
||||||
|
public Guid PreviewID { get; set; }
|
||||||
|
public List<ManuscriptScanChapterReviewFormItem> Chapters { get; set; } = [];
|
||||||
|
public List<ManuscriptScanSceneReviewFormItem> Scenes { get; set; } = [];
|
||||||
|
public List<ManuscriptScanCharacterReviewFormItem> Characters { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ManuscriptScanChapterReviewFormItem
|
||||||
|
{
|
||||||
|
public string TemporaryChapterKey { get; set; } = string.Empty;
|
||||||
|
public bool Include { get; set; }
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public int ChapterNumber { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ManuscriptScanSceneReviewFormItem
|
||||||
|
{
|
||||||
|
public string TemporarySceneKey { get; set; } = string.Empty;
|
||||||
|
public string TemporaryChapterKey { get; set; } = string.Empty;
|
||||||
|
public bool Include { get; set; }
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public int SceneNumberWithinChapter { get; set; }
|
||||||
|
public bool AllowZeroWords { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ManuscriptScanCharacterReviewFormItem
|
||||||
|
{
|
||||||
|
public string TemporaryCharacterKey { get; set; } = string.Empty;
|
||||||
|
public bool Include { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Category { get; set; } = "PossibleCharacter";
|
||||||
|
public int? ExistingCharacterID { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -310,9 +310,13 @@
|
|||||||
data-onboarding-scan-review
|
data-onboarding-scan-review
|
||||||
href="@(Model.ScanState.PreviewID.HasValue ? Url.Action("ScanReview", "Onboarding", new { previewId = Model.ScanState.PreviewID }) : "#")"
|
href="@(Model.ScanState.PreviewID.HasValue ? Url.Action("ScanReview", "Onboarding", new { previewId = Model.ScanState.PreviewID }) : "#")"
|
||||||
aria-disabled="@(!Model.ScanState.IsComplete)">
|
aria-disabled="@(!Model.ScanState.IsComplete)">
|
||||||
Review scan
|
@(string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal) ? "Edit review" : "Review scan")
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@if (string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
<p class="onboarding-scan-next">Next: import into PlotDirector.</p>
|
||||||
|
}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@ -16,32 +16,50 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<section class="onboarding-shell" aria-labelledby="scan-review-title">
|
<section class="onboarding-shell" aria-labelledby="scan-review-title">
|
||||||
<div class="onboarding-panel onboarding-review-panel">
|
<form class="onboarding-panel onboarding-review-panel" asp-controller="Onboarding" asp-action="SaveScanReview" method="post">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<input type="hidden" asp-for="PreviewID" name="PreviewID" />
|
||||||
|
|
||||||
<div class="onboarding-copy">
|
<div class="onboarding-copy">
|
||||||
<p class="eyebrow">@Model.SelectedBookTitle</p>
|
<p class="eyebrow">@Model.SelectedBookTitle</p>
|
||||||
<h1 id="scan-review-title">Review manuscript scan</h1>
|
<h1 id="scan-review-title">Review manuscript scan</h1>
|
||||||
<p>@Model.DocumentTitle</p>
|
<p>Nothing has been added to your project yet. Review the detected structure, then continue when you're happy.</p>
|
||||||
|
<p>You can tidy this up now, or import the structure and refine it later.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success">@reviewMessage</div>
|
||||||
|
}
|
||||||
|
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||||
|
|
||||||
<div class="onboarding-scan-counts onboarding-review-counts">
|
<div class="onboarding-scan-counts onboarding-review-counts">
|
||||||
<div>
|
<div>
|
||||||
<span>Chapters</span>
|
<span>Chapters</span>
|
||||||
<strong>@Model.ChapterCount</strong>
|
<strong>@Model.SelectedChapterCount / @Model.ChapterCount</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Scenes</span>
|
<span>Scenes</span>
|
||||||
<strong>@Model.SceneCount</strong>
|
<strong>@Model.SelectedSceneCount / @Model.SceneCount</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Characters</span>
|
<span>Characters</span>
|
||||||
<strong>@Model.CharacterCandidateCount</strong>
|
<strong>@Model.SelectedCharacterCount / @Model.CharacterCandidateCount</strong>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span>Words</span>
|
<span>Words</span>
|
||||||
<strong>@Model.TotalWordCount.ToString("N0")</strong>
|
<strong>@Model.SelectedWordCount.ToString("N0")</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="onboarding-review-tools" data-review-tools>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="chapter" data-bulk-state="true">Include all chapters</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="chapter" data-bulk-state="false">Exclude all chapters</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="scene" data-bulk-state="true">Include all scenes</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="scene" data-bulk-state="false">Exclude all scenes</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="reset">Restore scan defaults</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="onboarding-review-grid" aria-label="Scan preview">
|
<section class="onboarding-review-grid" aria-label="Scan preview">
|
||||||
<div class="onboarding-review-section">
|
<div class="onboarding-review-section">
|
||||||
<h2>Chapters and scenes</h2>
|
<h2>Chapters and scenes</h2>
|
||||||
@ -52,27 +70,63 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<div class="onboarding-review-chapters">
|
<div class="onboarding-review-chapters">
|
||||||
|
@{
|
||||||
|
var chapterIndex = 0;
|
||||||
|
var sceneIndex = 0;
|
||||||
|
}
|
||||||
@foreach (var chapter in Model.Chapters)
|
@foreach (var chapter in Model.Chapters)
|
||||||
{
|
{
|
||||||
<article class="onboarding-review-chapter">
|
<details class="onboarding-review-chapter" open>
|
||||||
<header>
|
<summary>
|
||||||
<span>Chapter @chapter.ChapterNumber</span>
|
<span>Chapter @chapter.ChapterNumber</span>
|
||||||
<strong>@chapter.Title</strong>
|
<strong>@chapter.ReviewTitle</strong>
|
||||||
<small>@chapter.WordCount.ToString("N0") words / @chapter.Scenes.Count scene@(chapter.Scenes.Count == 1 ? string.Empty : "s")</small>
|
<small>@chapter.WordCount.ToString("N0") words / @chapter.Scenes.Count scene@(chapter.Scenes.Count == 1 ? string.Empty : "s")</small>
|
||||||
</header>
|
</summary>
|
||||||
|
<input type="hidden" name="Chapters[@chapterIndex].TemporaryChapterKey" value="@chapter.TemporaryChapterKey" />
|
||||||
|
<input type="hidden" name="Chapters[@chapterIndex].ChapterNumber" value="@chapter.ChapterNumber" />
|
||||||
|
<label class="onboarding-review-toggle">
|
||||||
|
<input type="hidden" name="Chapters[@chapterIndex].Include" value="false" />
|
||||||
|
<input type="checkbox" name="Chapters[@chapterIndex].Include" value="true" checked="@chapter.Include" data-review-kind="chapter" />
|
||||||
|
Include chapter
|
||||||
|
</label>
|
||||||
|
<label class="onboarding-review-field">
|
||||||
|
Chapter title
|
||||||
|
<input class="form-control" name="Chapters[@chapterIndex].Title" value="@chapter.ReviewTitle" />
|
||||||
|
</label>
|
||||||
|
|
||||||
@if (chapter.Scenes.Any())
|
@if (chapter.Scenes.Any())
|
||||||
{
|
{
|
||||||
<ol>
|
<ol>
|
||||||
@foreach (var scene in chapter.Scenes)
|
@foreach (var scene in chapter.Scenes)
|
||||||
{
|
{
|
||||||
<li>
|
<li>
|
||||||
<strong>@(scene.Title ?? $"Scene {scene.SceneNumberWithinChapter}")</strong>
|
<input type="hidden" name="Scenes[@sceneIndex].TemporarySceneKey" value="@scene.TemporarySceneKey" />
|
||||||
|
<input type="hidden" name="Scenes[@sceneIndex].TemporaryChapterKey" value="@scene.TemporaryChapterKey" />
|
||||||
|
<input type="hidden" name="Scenes[@sceneIndex].SceneNumberWithinChapter" value="@scene.SceneNumberWithinChapter" />
|
||||||
|
<label class="onboarding-review-toggle">
|
||||||
|
<input type="hidden" name="Scenes[@sceneIndex].Include" value="false" />
|
||||||
|
<input type="checkbox" name="Scenes[@sceneIndex].Include" value="true" checked="@scene.Include" data-review-kind="scene" />
|
||||||
|
Include scene
|
||||||
|
</label>
|
||||||
|
<label class="onboarding-review-field">
|
||||||
|
Scene title
|
||||||
|
<input class="form-control" name="Scenes[@sceneIndex].Title" value="@scene.ReviewTitle" placeholder="Scene @scene.SceneNumberWithinChapter" />
|
||||||
|
</label>
|
||||||
<span>@scene.WordCount.ToString("N0") words</span>
|
<span>@scene.WordCount.ToString("N0") words</span>
|
||||||
|
@if (scene.WordCount == 0)
|
||||||
|
{
|
||||||
|
<label class="onboarding-review-toggle">
|
||||||
|
<input type="hidden" name="Scenes[@sceneIndex].AllowZeroWords" value="false" />
|
||||||
|
<input type="checkbox" name="Scenes[@sceneIndex].AllowZeroWords" value="true" />
|
||||||
|
Allow empty scene
|
||||||
|
</label>
|
||||||
|
}
|
||||||
@if (!string.IsNullOrWhiteSpace(scene.OpeningTextPreview))
|
@if (!string.IsNullOrWhiteSpace(scene.OpeningTextPreview))
|
||||||
{
|
{
|
||||||
<p>@scene.OpeningTextPreview</p>
|
<p>@scene.OpeningTextPreview</p>
|
||||||
}
|
}
|
||||||
</li>
|
</li>
|
||||||
|
sceneIndex++;
|
||||||
}
|
}
|
||||||
</ol>
|
</ol>
|
||||||
}
|
}
|
||||||
@ -80,7 +134,8 @@
|
|||||||
{
|
{
|
||||||
<p>No scene breaks were detected. PlotDirector will treat this chapter as one scene for now.</p>
|
<p>No scene breaks were detected. PlotDirector will treat this chapter as one scene for now.</p>
|
||||||
}
|
}
|
||||||
</article>
|
</details>
|
||||||
|
chapterIndex++;
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@ -94,72 +149,103 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
var characterIndex = 0;
|
||||||
<div class="onboarding-character-groups">
|
<div class="onboarding-character-groups">
|
||||||
<section>
|
@foreach (var group in new[]
|
||||||
<h3>Probable characters</h3>
|
|
||||||
@if (!probableCharacters.Any())
|
|
||||||
{
|
|
||||||
<p>No high-confidence character names yet.</p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<ul class="onboarding-character-candidates">
|
|
||||||
@foreach (var candidate in probableCharacters)
|
|
||||||
{
|
|
||||||
<li>
|
|
||||||
<strong>@candidate.Name</strong>
|
|
||||||
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
@if (possibleCharacters.Any())
|
|
||||||
{
|
{
|
||||||
|
new { Title = "Probable characters", Candidates = probableCharacters },
|
||||||
|
new { Title = "Possible characters", Candidates = possibleCharacters },
|
||||||
|
new { Title = "Relationship titles", Candidates = relationshipTitles }
|
||||||
|
})
|
||||||
|
{
|
||||||
|
if (!group.Candidates.Any())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3>Possible characters</h3>
|
<h3>@group.Title</h3>
|
||||||
<ul class="onboarding-character-candidates">
|
<ul class="onboarding-character-candidates">
|
||||||
@foreach (var candidate in possibleCharacters)
|
@foreach (var candidate in group.Candidates)
|
||||||
{
|
{
|
||||||
<li>
|
<li>
|
||||||
<strong>@candidate.Name</strong>
|
<input type="hidden" name="Characters[@characterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
|
||||||
|
<input type="hidden" name="Characters[@characterIndex].Category" value="@candidate.Category" />
|
||||||
|
<label class="onboarding-review-toggle">
|
||||||
|
<input type="hidden" name="Characters[@characterIndex].Include" value="false" />
|
||||||
|
<input type="checkbox" name="Characters[@characterIndex].Include" value="true" checked="@candidate.Include" />
|
||||||
|
Include
|
||||||
|
</label>
|
||||||
|
<label class="onboarding-review-field">
|
||||||
|
Name
|
||||||
|
<input class="form-control" name="Characters[@characterIndex].Name" value="@candidate.ReviewName" />
|
||||||
|
</label>
|
||||||
|
<label class="onboarding-review-field">
|
||||||
|
Existing character
|
||||||
|
<select class="form-select" name="Characters[@characterIndex].ExistingCharacterID">
|
||||||
|
<option value="">Create later if imported</option>
|
||||||
|
@foreach (var option in Model.ExistingCharacterOptions)
|
||||||
|
{
|
||||||
|
<option value="@option.CharacterID" selected="@(candidate.ExistingCharacterID == option.CharacterID)">@option.CharacterName</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
|
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(candidate.ExistingCharacterName))
|
||||||
|
{
|
||||||
|
<small>Matches existing character: @candidate.ExistingCharacterName</small>
|
||||||
|
}
|
||||||
|
@if (!string.IsNullOrWhiteSpace(candidate.Reason))
|
||||||
|
{
|
||||||
|
<small>@candidate.Reason</small>
|
||||||
|
}
|
||||||
</li>
|
</li>
|
||||||
|
characterIndex++;
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (relationshipTitles.Any())
|
|
||||||
{
|
|
||||||
<section>
|
|
||||||
<h3>Relationship titles</h3>
|
|
||||||
<ul class="onboarding-character-candidates">
|
|
||||||
@foreach (var candidate in relationshipTitles)
|
|
||||||
{
|
|
||||||
<li>
|
|
||||||
<strong>@candidate.Name</strong>
|
|
||||||
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s")</span>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (excludedCharacters.Any())
|
@if (excludedCharacters.Any())
|
||||||
{
|
{
|
||||||
<details class="onboarding-excluded-candidates">
|
<details class="onboarding-excluded-candidates">
|
||||||
<summary>Show excluded candidates</summary>
|
<summary>Show excluded candidates</summary>
|
||||||
<ul class="onboarding-character-candidates">
|
<section>
|
||||||
@foreach (var candidate in excludedCharacters)
|
<h3>Excluded candidates</h3>
|
||||||
{
|
<ul class="onboarding-character-candidates">
|
||||||
<li>
|
@foreach (var candidate in excludedCharacters)
|
||||||
<strong>@candidate.Name</strong>
|
{
|
||||||
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / @candidate.Reason</span>
|
<li>
|
||||||
</li>
|
<input type="hidden" name="Characters[@characterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
|
||||||
}
|
<input type="hidden" name="Characters[@characterIndex].Category" value="@candidate.Category" />
|
||||||
</ul>
|
<label class="onboarding-review-toggle">
|
||||||
|
<input type="hidden" name="Characters[@characterIndex].Include" value="false" />
|
||||||
|
<input type="checkbox" name="Characters[@characterIndex].Include" value="true" checked="@candidate.Include" />
|
||||||
|
Include
|
||||||
|
</label>
|
||||||
|
<label class="onboarding-review-field">
|
||||||
|
Name
|
||||||
|
<input class="form-control" name="Characters[@characterIndex].Name" value="@candidate.ReviewName" />
|
||||||
|
</label>
|
||||||
|
<label class="onboarding-review-field">
|
||||||
|
Existing character
|
||||||
|
<select class="form-select" name="Characters[@characterIndex].ExistingCharacterID">
|
||||||
|
<option value="">Create later if imported</option>
|
||||||
|
@foreach (var option in Model.ExistingCharacterOptions)
|
||||||
|
{
|
||||||
|
<option value="@option.CharacterID" selected="@(candidate.ExistingCharacterID == option.CharacterID)">@option.CharacterName</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(candidate.Reason))
|
||||||
|
{
|
||||||
|
<small>@candidate.Reason</small>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
characterIndex++;
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
</details>
|
</details>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@ -169,6 +255,23 @@
|
|||||||
|
|
||||||
<div class="onboarding-actions">
|
<div class="onboarding-actions">
|
||||||
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="Index">Back to setup</a>
|
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="Index">Back to setup</a>
|
||||||
|
<button class="btn btn-outline-primary" type="submit" name="intent" value="save">Save review</button>
|
||||||
|
<button class="btn btn-primary" type="submit" name="intent" value="continue">Save and continue</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.querySelector("[data-review-tools]")?.addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest("[data-bulk-target]");
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = button.dataset.bulkTarget;
|
||||||
|
const checked = button.dataset.bulkState === "true";
|
||||||
|
document.querySelectorAll(`[data-review-kind="${target}"]`).forEach((checkbox) => {
|
||||||
|
checkbox.checked = checked;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|||||||
@ -309,6 +309,12 @@
|
|||||||
gap: .65rem;
|
gap: .65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.onboarding-scan-next {
|
||||||
|
margin: .25rem 0 0;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.onboarding-review-panel {
|
.onboarding-review-panel {
|
||||||
width: min(1120px, 100%);
|
width: min(1120px, 100%);
|
||||||
}
|
}
|
||||||
@ -317,6 +323,13 @@
|
|||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.onboarding-review-tools {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.onboarding-review-grid {
|
.onboarding-review-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.7fr) minmax(280px, .8fr);
|
grid-template-columns: minmax(0, 1.7fr) minmax(280px, .8fr);
|
||||||
@ -346,14 +359,15 @@
|
|||||||
background: rgba(255, 255, 255, .58);
|
background: rgba(255, 255, 255, .58);
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-review-chapter header {
|
.onboarding-review-chapter summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: .2rem;
|
gap: .2rem;
|
||||||
margin-bottom: .75rem;
|
margin-bottom: .75rem;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-review-chapter header span,
|
.onboarding-review-chapter summary span,
|
||||||
.onboarding-review-chapter header small,
|
.onboarding-review-chapter summary small,
|
||||||
.onboarding-review-chapter li span {
|
.onboarding-review-chapter li span {
|
||||||
color: var(--bs-secondary-color);
|
color: var(--bs-secondary-color);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@ -379,7 +393,7 @@
|
|||||||
|
|
||||||
.onboarding-review-chapter li {
|
.onboarding-review-chapter li {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: .2rem;
|
gap: .45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-review-chapter li p {
|
.onboarding-review-chapter li p {
|
||||||
@ -388,10 +402,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-character-candidates li {
|
.onboarding-character-candidates li {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-review-toggle {
|
||||||
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: .45rem;
|
||||||
gap: .75rem;
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-review-field {
|
||||||
|
display: grid;
|
||||||
|
gap: .25rem;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-character-groups section {
|
.onboarding-character-groups section {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user