1455 lines
66 KiB
C#
1455 lines
66 KiB
C#
using System.Text.Json;
|
|
using PlotLine.Data;
|
|
using PlotLine.Models;
|
|
using PlotLine.ViewModels;
|
|
|
|
namespace PlotLine.Services;
|
|
|
|
public interface IOnboardingService
|
|
{
|
|
Task<UserOnboardingState> GetOrCreateAsync();
|
|
Task<OnboardingWizardViewModel> GetWizardAsync();
|
|
Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null);
|
|
Task<ManuscriptScanReviewViewModel?> SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport);
|
|
Task<OnboardingManuscriptBuildResult?> FinaliseCharacterReviewAsync(ManuscriptScanReviewForm form);
|
|
Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, Func<OnboardingBuildProgress, Task>? progress = null);
|
|
Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId);
|
|
Task<bool> ShouldShowOnboardingAsync();
|
|
Task<OnboardingNudgeViewModel> GetDashboardNudgeAsync();
|
|
Task<bool> ShouldShowWordCompanionHeaderAsync();
|
|
Task MoveToNextStepAsync();
|
|
Task StartManualSetupAsync();
|
|
Task StartManuscriptImportSetupAsync();
|
|
Task BeginManuscriptImportForBookAsync(int projectId, int bookId);
|
|
Task SetCurrentStepAsync(string currentStep);
|
|
Task SaveWritingJourneyAsync(string writingJourney);
|
|
Task SaveWritingSoftwareAsync(string writingSoftware);
|
|
Task SaveProjectAsync(ProjectOnboardingForm form);
|
|
Task SaveBookAsync(BookOnboardingForm form);
|
|
Task MarkCompletedAsync();
|
|
}
|
|
|
|
public sealed class OnboardingService(
|
|
IOnboardingRepository onboarding,
|
|
IProjectRepository projects,
|
|
IBookRepository books,
|
|
IBookService bookService,
|
|
IProjectActivityService activity,
|
|
IWordCompanionPresenceService companionPresence,
|
|
IManuscriptScanPreviewStore scanPreviews,
|
|
ICharacterRepository characters,
|
|
IOnboardingBuildRepository builds,
|
|
IManuscriptStructureAnalysisService structureAnalysis,
|
|
ICoreImportCharacterMappingService coreImportCharacterMapping,
|
|
ICurrentUserService currentUser) : IOnboardingService
|
|
{
|
|
private static readonly HashSet<string> WritingJourneys = new(StringComparer.Ordinal)
|
|
{
|
|
WritingJourneyValues.PlanningNewStory,
|
|
WritingJourneyValues.AlreadyStarted,
|
|
WritingJourneyValues.ManuscriptMostlyComplete
|
|
};
|
|
|
|
private static readonly HashSet<string> WritingSoftware = new(StringComparer.Ordinal)
|
|
{
|
|
WritingSoftwareValues.MicrosoftWord,
|
|
WritingSoftwareValues.GoogleDocs,
|
|
WritingSoftwareValues.Scrivener,
|
|
WritingSoftwareValues.LibreOfficeOpenOffice,
|
|
WritingSoftwareValues.MarkdownObsidian,
|
|
WritingSoftwareValues.Other
|
|
};
|
|
|
|
public async Task<UserOnboardingState> GetOrCreateAsync()
|
|
=> await onboarding.GetOrCreateAsync(RequireUserId());
|
|
|
|
public async Task<OnboardingWizardViewModel> GetWizardAsync()
|
|
{
|
|
var state = await GetOrCreateAsync();
|
|
var step = ResolveStep(state);
|
|
var projectOptions = await BuildProjectOptionsAsync();
|
|
var bookOptions = state.ProjectID.HasValue
|
|
? await BuildBookOptionsAsync(state.ProjectID.Value)
|
|
: [];
|
|
var selectedProject = state.ProjectID.HasValue
|
|
? await projects.GetForUserAsync(state.ProjectID.Value, RequireUserId())
|
|
: null;
|
|
var selectedBook = state.BookID.HasValue
|
|
? await books.GetAsync(state.BookID.Value)
|
|
: null;
|
|
var presence = await companionPresence.GetStatusAsync(RequireUserId());
|
|
var scanState = await scanPreviews.GetStateAsync(RequireUserId(), state.UserOnboardingStateID);
|
|
|
|
return new OnboardingWizardViewModel
|
|
{
|
|
CurrentStep = step,
|
|
StepNumber = StepNumber(step),
|
|
TotalSteps = 11,
|
|
WritingJourney = state.WritingJourney,
|
|
WritingSoftware = state.WritingSoftware,
|
|
ProjectID = state.ProjectID,
|
|
BookID = state.BookID,
|
|
SelectedProjectName = selectedProject?.ProjectName,
|
|
SelectedBookTitle = selectedBook is null ? null : BookOptionTitle(selectedBook),
|
|
CompanionPresence = ToPresenceViewModel(presence),
|
|
ScanState = ToScanStateViewModel(scanState),
|
|
ProjectOptions = projectOptions,
|
|
BookOptions = bookOptions,
|
|
ProjectForm = new ProjectOnboardingForm
|
|
{
|
|
Mode = projectOptions.Count == 0 || !state.ProjectID.HasValue ? OnboardingSelectionModes.Create : OnboardingSelectionModes.Select,
|
|
SelectedProjectID = state.ProjectID
|
|
},
|
|
BookForm = new BookOnboardingForm
|
|
{
|
|
Mode = bookOptions.Count == 0 || !state.BookID.HasValue ? OnboardingSelectionModes.Create : OnboardingSelectionModes.Select,
|
|
SelectedBookID = state.BookID
|
|
},
|
|
Options = step switch
|
|
{
|
|
OnboardingSteps.WritingJourney => WritingJourneyOptions(),
|
|
OnboardingSteps.WritingSoftware => WritingSoftwareOptions(),
|
|
_ => []
|
|
}
|
|
};
|
|
}
|
|
|
|
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 aliasTargetKey = CleanOptional(item.AliasTargetTemporaryCharacterKey);
|
|
if (!string.IsNullOrWhiteSpace(aliasTargetKey)
|
|
&& (!candidateLookup.ContainsKey(aliasTargetKey) || string.Equals(aliasTargetKey, item.TemporaryCharacterKey, StringComparison.Ordinal)))
|
|
{
|
|
aliasTargetKey = null;
|
|
}
|
|
|
|
var matchedExistingId = string.IsNullOrWhiteSpace(aliasTargetKey) && item.ExistingCharacterID.HasValue && existingCharacterIds.Contains(item.ExistingCharacterID.Value)
|
|
? item.ExistingCharacterID
|
|
: string.IsNullOrWhiteSpace(aliasTargetKey) ? MatchExistingCharacter(cleanName, existingCharacters)?.CharacterID : null;
|
|
return new ManuscriptScanCharacterReviewDecision
|
|
{
|
|
TemporaryCharacterKey = item.TemporaryCharacterKey,
|
|
Include = item.Include,
|
|
Name = cleanName,
|
|
Category = item.Category,
|
|
ExistingCharacterID = matchedExistingId,
|
|
AliasTargetTemporaryCharacterKey = aliasTargetKey,
|
|
SexHint = CharacterSexHintService.Normalise(item.SexHint, cleanName)
|
|
};
|
|
})
|
|
.ToList();
|
|
|
|
var review = new ManuscriptScanReviewDecision
|
|
{
|
|
PreviewID = preview.PreviewID,
|
|
Status = ManuscriptScanReviewStatuses.ReviewInProgress,
|
|
Chapters = chapterDecisions,
|
|
Scenes = sceneDecisions,
|
|
Characters = characterDecisions
|
|
};
|
|
|
|
if (readyToImport)
|
|
{
|
|
await scanPreviews.SaveReviewAsync(userId, review);
|
|
ValidateScanStructure(review, preview);
|
|
review = new ManuscriptScanReviewDecision
|
|
{
|
|
PreviewID = review.PreviewID,
|
|
Status = ManuscriptScanReviewStatuses.ReadyToImport,
|
|
Chapters = review.Chapters,
|
|
Scenes = review.Scenes,
|
|
Characters = review.Characters
|
|
};
|
|
}
|
|
else
|
|
{
|
|
ValidateScanStructure(review, preview);
|
|
}
|
|
|
|
var saved = await scanPreviews.SaveReviewAsync(userId, review);
|
|
return saved is null ? null : BuildScanReviewViewModel((userId, preview, saved, project, book, existingCharacters));
|
|
}
|
|
|
|
public async Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, Func<OnboardingBuildProgress, Task>? progress = null)
|
|
{
|
|
var context = await GetScanReviewContextAsync(previewId);
|
|
if (context is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var (userId, preview, review, _, _, _) = context.Value;
|
|
if (!string.Equals(review.Status, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
|
|
{
|
|
throw new InvalidOperationException("Review the scan and choose Save and continue before preparing chapters.");
|
|
}
|
|
|
|
var existingBuild = await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID)
|
|
?? await builds.GetByPreviewAsync(userId, preview.PreviewID);
|
|
if (existingBuild is not null && existingBuild.SceneMappings.Count > 0)
|
|
{
|
|
var resumed = await BuildExistingStructureResultAsync(userId, preview, review, existingBuild);
|
|
await scanPreviews.SaveBuildResultAsync(userId, resumed);
|
|
await PublishBuildProgress(userId, preview.PreviewID, "Resuming Word marker sync. No scene detection was rerun.", 90, progress, resumed);
|
|
return resumed;
|
|
}
|
|
|
|
await PublishBuildProgress(userId, preview.PreviewID, "Preparing approved chapters for scene detection...", 10, progress);
|
|
ValidateScanStructure(review, preview);
|
|
|
|
var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList();
|
|
|
|
await PublishBuildProgress(userId, preview.PreviewID, "Detecting scene boundaries...", 20, progress);
|
|
var structuralScenes = new List<ManuscriptStructureSceneBoundary>();
|
|
for (var chapterIndex = 0; chapterIndex < chapterDecisions.Count; chapterIndex++)
|
|
{
|
|
var chapterDecision = chapterDecisions[chapterIndex];
|
|
var chapterPreview = preview.Chapters.FirstOrDefault(item => item.TemporaryChapterKey == chapterDecision.TemporaryChapterKey)
|
|
?? throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' could not be found in the scan preview.");
|
|
if (string.IsNullOrWhiteSpace(chapterPreview.ChapterText))
|
|
{
|
|
throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' has no scanned text. Scan the manuscript again before importing.");
|
|
}
|
|
|
|
if (chapterPreview.ParagraphMap.Count == 0)
|
|
{
|
|
throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' does not have paragraph mapping. Scan the manuscript again before importing.");
|
|
}
|
|
|
|
var analysisPercent = 20 + (chapterIndex * 20 / Math.Max(1, chapterDecisions.Count));
|
|
await PublishBuildProgress(
|
|
userId,
|
|
preview.PreviewID,
|
|
$"Analysing Chapter {chapterIndex + 1:N0} of {chapterDecisions.Count:N0} - {chapterDecision.Title}",
|
|
analysisPercent,
|
|
progress);
|
|
var analysis = await structureAnalysis.AnalyseChapterAsync(new ManuscriptStructureAnalysisRequest
|
|
{
|
|
ProjectID = preview.ProjectID,
|
|
BookID = preview.BookID,
|
|
PreviewID = preview.PreviewID,
|
|
TemporaryChapterKey = chapterDecision.TemporaryChapterKey,
|
|
ChapterNumber = chapterDecision.ChapterNumber,
|
|
SourceLabel = $"{preview.DocumentTitle ?? "Word manuscript"}: {chapterDecision.Title}",
|
|
ChapterText = chapterPreview.ChapterText,
|
|
ParagraphMap = chapterPreview.ParagraphMap,
|
|
SourceParagraphs = chapterPreview.SourceParagraphs
|
|
});
|
|
|
|
if (!analysis.Success)
|
|
{
|
|
throw new InvalidOperationException($"Scene boundary detection failed for '{chapterDecision.Title}': {analysis.ErrorMessage}");
|
|
}
|
|
|
|
structuralScenes.AddRange(analysis.Scenes);
|
|
}
|
|
|
|
await PublishBuildProgress(userId, preview.PreviewID, "Validating detected scene structure...", 45, progress);
|
|
var request = new OnboardingManuscriptBuildRequest
|
|
{
|
|
PreviewID = preview.PreviewID,
|
|
ProjectID = preview.ProjectID,
|
|
BookID = preview.BookID,
|
|
DocumentGuid = Guid.TryParse(preview.CompanionDocumentIdentifier, out var documentGuid) ? documentGuid : Guid.NewGuid(),
|
|
Chapters = chapterDecisions.Select((chapter, index) => new OnboardingBuildChapterRequest
|
|
{
|
|
TemporaryChapterKey = chapter.TemporaryChapterKey,
|
|
Title = chapter.Title,
|
|
SortOrder = (index + 1) * 10,
|
|
WordCount = preview.Chapters.FirstOrDefault(item => item.TemporaryChapterKey == chapter.TemporaryChapterKey)?.WordCount ?? 0
|
|
}).ToList(),
|
|
Scenes = structuralScenes.Select(scene => new OnboardingBuildSceneRequest
|
|
{
|
|
TemporarySceneKey = scene.TemporarySceneKey,
|
|
TemporaryChapterKey = scene.TemporaryChapterKey,
|
|
Title = scene.Title,
|
|
SortOrder = scene.SceneNumberWithinChapter * 10,
|
|
WordCount = scene.WordCount
|
|
}).ToList(),
|
|
Characters = []
|
|
};
|
|
|
|
await PublishBuildProgress(userId, preview.PreviewID, "Creating PlotDirector scenes...", 60, progress);
|
|
var result = await builds.BuildAsync(userId, request)
|
|
?? throw new InvalidOperationException($"The project chapters could not be prepared. Approved chapters: {request.Chapters.Count}. Return to review, save your selections, and try again.");
|
|
result = new OnboardingManuscriptBuildResult
|
|
{
|
|
BuildID = result.BuildID,
|
|
PreviewID = result.PreviewID,
|
|
ProjectID = result.ProjectID,
|
|
BookID = result.BookID,
|
|
ManuscriptDocumentID = result.ManuscriptDocumentID,
|
|
Status = result.Status,
|
|
MarkerStatus = result.MarkerStatus,
|
|
ChaptersCreated = result.ChaptersCreated,
|
|
ScenesCreated = result.ScenesCreated,
|
|
CharactersCreated = result.CharactersCreated,
|
|
CharactersReused = result.CharactersReused,
|
|
SceneAppearancesCreated = result.SceneAppearancesCreated,
|
|
AlreadyBuilt = result.AlreadyBuilt,
|
|
Message = "Manuscript structure created. Updating and saving Word markers...",
|
|
ChapterMappings = result.ChapterMappings,
|
|
SceneMappings = result.SceneMappings,
|
|
CharacterMappings = result.CharacterMappings,
|
|
ChapterMarkerInstructions = BuildChapterMarkerInstructions(preview, result.ChapterMappings),
|
|
SceneMarkerInstructions = BuildSceneMarkerInstructions(structuralScenes, result.SceneMappings),
|
|
SceneStructure = BuildSceneStructureSummary(chapterDecisions, structuralScenes),
|
|
MarkerWarning = result.MarkerWarning
|
|
};
|
|
await SaveStructuralChapterSourcesAsync(preview, result);
|
|
await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result);
|
|
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
|
|
var canonicalSceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID);
|
|
var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(canonicalSceneSources);
|
|
await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates);
|
|
await scanPreviews.SaveBuildResultAsync(userId, result);
|
|
await PublishBuildProgress(userId, preview.PreviewID, "Updating Word document with scene markers. Keep Word open while the document is saved.", 90, progress);
|
|
return result;
|
|
}
|
|
|
|
private async Task<OnboardingManuscriptBuildResult> BuildExistingStructureResultAsync(
|
|
int userId,
|
|
ManuscriptScanPreview preview,
|
|
ManuscriptScanReviewDecision review,
|
|
OnboardingManuscriptBuildResult existingBuild)
|
|
{
|
|
var sceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID);
|
|
var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(sceneSources);
|
|
await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates);
|
|
|
|
return new OnboardingManuscriptBuildResult
|
|
{
|
|
BuildID = existingBuild.BuildID,
|
|
PreviewID = existingBuild.PreviewID,
|
|
ProjectID = existingBuild.ProjectID,
|
|
BookID = existingBuild.BookID,
|
|
ManuscriptDocumentID = existingBuild.ManuscriptDocumentID,
|
|
Status = existingBuild.Status,
|
|
MarkerStatus = existingBuild.MarkerStatus,
|
|
ChaptersCreated = existingBuild.ChaptersCreated,
|
|
ScenesCreated = existingBuild.ScenesCreated,
|
|
CharactersCreated = existingBuild.CharactersCreated,
|
|
CharactersReused = existingBuild.CharactersReused,
|
|
SceneAppearancesCreated = existingBuild.SceneAppearancesCreated,
|
|
AlreadyBuilt = true,
|
|
Message = "Manuscript structure already exists. Updating and saving Word markers...",
|
|
ChapterMappings = existingBuild.ChapterMappings,
|
|
SceneMappings = existingBuild.SceneMappings,
|
|
CharacterMappings = existingBuild.CharacterMappings,
|
|
ChapterMarkerInstructions = BuildChapterMarkerInstructions(preview, existingBuild.ChapterMappings),
|
|
SceneMarkerInstructions = BuildSceneMarkerInstructionsFromSources(sceneSources, existingBuild.SceneMappings),
|
|
SceneStructure = BuildSceneStructureSummaryFromSources(review.Chapters.Where(chapter => chapter.Include).ToList(), sceneSources),
|
|
MarkerWarning = existingBuild.MarkerWarning
|
|
};
|
|
}
|
|
|
|
public async Task<OnboardingManuscriptBuildResult?> GetBuildResultAsync(Guid previewId)
|
|
=> await scanPreviews.GetBuildResultAsync(RequireUserId(), previewId);
|
|
|
|
public async Task<OnboardingManuscriptBuildResult?> FinaliseCharacterReviewAsync(ManuscriptScanReviewForm form)
|
|
{
|
|
var context = await GetScanReviewContextAsync(form.PreviewID);
|
|
if (context is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var (userId, preview, review, _, _, existingCharacters) = context.Value;
|
|
var build = await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID);
|
|
if (build is null || build.SceneMappings.Count == 0)
|
|
{
|
|
throw new InvalidOperationException("Import the approved chapters and scenes before reviewing characters.");
|
|
}
|
|
|
|
var candidateLookup = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
|
|
var existingCharacterIds = existingCharacters.Select(character => character.CharacterID).ToHashSet();
|
|
var characterDecisions = form.Characters
|
|
.Where(item => candidateLookup.ContainsKey(item.TemporaryCharacterKey))
|
|
.Select(item =>
|
|
{
|
|
var cleanName = Clean(item.Name);
|
|
var aliasTargetKey = CleanOptional(item.AliasTargetTemporaryCharacterKey);
|
|
if (!string.IsNullOrWhiteSpace(aliasTargetKey)
|
|
&& (!candidateLookup.ContainsKey(aliasTargetKey) || string.Equals(aliasTargetKey, item.TemporaryCharacterKey, StringComparison.Ordinal)))
|
|
{
|
|
aliasTargetKey = null;
|
|
}
|
|
|
|
var matchedExistingId = string.IsNullOrWhiteSpace(aliasTargetKey) && item.ExistingCharacterID.HasValue && existingCharacterIds.Contains(item.ExistingCharacterID.Value)
|
|
? item.ExistingCharacterID
|
|
: string.IsNullOrWhiteSpace(aliasTargetKey) ? MatchExistingCharacter(cleanName, existingCharacters)?.CharacterID : null;
|
|
return new ManuscriptScanCharacterReviewDecision
|
|
{
|
|
TemporaryCharacterKey = item.TemporaryCharacterKey,
|
|
Include = item.Include,
|
|
Name = cleanName,
|
|
Category = item.Category,
|
|
ExistingCharacterID = matchedExistingId,
|
|
AliasTargetTemporaryCharacterKey = aliasTargetKey,
|
|
SexHint = CharacterSexHintService.Normalise(item.SexHint, cleanName)
|
|
};
|
|
})
|
|
.ToList();
|
|
ValidateScanReview(new ManuscriptScanReviewDecision
|
|
{
|
|
PreviewID = review.PreviewID,
|
|
Status = review.Status,
|
|
Chapters = review.Chapters,
|
|
Scenes = review.Scenes,
|
|
Characters = characterDecisions
|
|
}, preview);
|
|
|
|
var characterMappings = new List<OnboardingBuildCharacterMap>();
|
|
foreach (var decision in characterDecisions.Where(IsIncludedCanonicalCharacter).OrderBy(item => item.Name).ThenBy(item => item.TemporaryCharacterKey))
|
|
{
|
|
var characterId = decision.ExistingCharacterID
|
|
?? MatchExistingCharacter(decision.Name, existingCharacters)?.CharacterID
|
|
?? await characters.SaveCharacterAsync(new Character
|
|
{
|
|
ProjectID = preview.ProjectID,
|
|
CharacterName = decision.Name,
|
|
Sex = string.IsNullOrWhiteSpace(decision.SexHint) ? null : decision.SexHint,
|
|
CharacterImportance = 8,
|
|
ShowInQuickAddBar = true
|
|
});
|
|
|
|
characterMappings.Add(new OnboardingBuildCharacterMap
|
|
{
|
|
TemporaryCharacterKey = decision.TemporaryCharacterKey,
|
|
CharacterID = characterId,
|
|
Created = !decision.ExistingCharacterID.HasValue
|
|
&& existingCharacters.All(character => character.CharacterID != characterId)
|
|
});
|
|
}
|
|
|
|
var completedReview = new ManuscriptScanReviewDecision
|
|
{
|
|
PreviewID = review.PreviewID,
|
|
Status = ManuscriptScanReviewStatuses.CoreImportComplete,
|
|
Chapters = review.Chapters,
|
|
Scenes = review.Scenes,
|
|
Characters = characterDecisions
|
|
};
|
|
|
|
var completedResult = new OnboardingManuscriptBuildResult
|
|
{
|
|
BuildID = build.BuildID,
|
|
PreviewID = build.PreviewID,
|
|
ProjectID = build.ProjectID,
|
|
BookID = build.BookID,
|
|
ManuscriptDocumentID = build.ManuscriptDocumentID,
|
|
Status = "CoreImportComplete",
|
|
MarkerStatus = build.MarkerStatus,
|
|
ChaptersCreated = build.ChaptersCreated,
|
|
ScenesCreated = build.ScenesCreated,
|
|
CharactersCreated = characterMappings.Count(item => item.Created),
|
|
CharactersReused = characterMappings.Count(item => !item.Created),
|
|
SceneAppearancesCreated = build.SceneAppearancesCreated,
|
|
AlreadyBuilt = build.AlreadyBuilt,
|
|
Message = "Core manuscript import complete.",
|
|
ChapterMappings = build.ChapterMappings,
|
|
SceneMappings = build.SceneMappings,
|
|
CharacterMappings = characterMappings,
|
|
ChapterMarkerInstructions = build.ChapterMarkerInstructions,
|
|
SceneMarkerInstructions = build.SceneMarkerInstructions,
|
|
SceneStructure = build.SceneStructure,
|
|
MarkerWarning = build.MarkerWarning
|
|
};
|
|
|
|
await scanPreviews.SaveReviewAsync(userId, completedReview);
|
|
await scanPreviews.SaveBuildResultAsync(userId, completedResult);
|
|
await AddScanCharacterAliasesAsync(completedReview, completedResult);
|
|
await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID);
|
|
return completedResult;
|
|
}
|
|
|
|
private static IReadOnlyList<OnboardingBuildMarkerInstruction> BuildChapterMarkerInstructions(
|
|
ManuscriptScanPreview preview,
|
|
IReadOnlyList<OnboardingBuildChapterMap> mappings)
|
|
{
|
|
var byKey = mappings.ToDictionary(item => item.TemporaryChapterKey, StringComparer.Ordinal);
|
|
return preview.Chapters
|
|
.Where(chapter => chapter.StartPosition.HasValue && byKey.ContainsKey(chapter.TemporaryChapterKey))
|
|
.Select(chapter =>
|
|
{
|
|
var map = byKey[chapter.TemporaryChapterKey];
|
|
return new OnboardingBuildMarkerInstruction
|
|
{
|
|
TemporaryKey = chapter.TemporaryChapterKey,
|
|
EntityID = map.ChapterID,
|
|
WordParagraphIndex = chapter.StartPosition!.Value,
|
|
MarkerTag = $"PD-CHAPTER-{map.ChapterID}",
|
|
MarkerTitle = "PlotDirector Chapter",
|
|
MarkerPrefix = "PD-CHAPTER"
|
|
};
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
private static IReadOnlyList<OnboardingBuildMarkerInstruction> BuildSceneMarkerInstructions(
|
|
IReadOnlyList<ManuscriptStructureSceneBoundary> scenes,
|
|
IReadOnlyList<OnboardingBuildSceneMap> mappings)
|
|
{
|
|
var byKey = mappings.ToDictionary(item => item.TemporarySceneKey, StringComparer.Ordinal);
|
|
return scenes
|
|
.Where(scene => byKey.ContainsKey(scene.TemporarySceneKey))
|
|
.Select(scene =>
|
|
{
|
|
var map = byKey[scene.TemporarySceneKey];
|
|
return new OnboardingBuildMarkerInstruction
|
|
{
|
|
TemporaryKey = scene.TemporarySceneKey,
|
|
EntityID = map.SceneID,
|
|
WordParagraphIndex = scene.WordStartPosition,
|
|
MarkerTag = $"PD-SCENE-{map.SceneID}",
|
|
MarkerTitle = "PlotDirector Scene",
|
|
MarkerPrefix = "PD-SCENE"
|
|
};
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
private static IReadOnlyList<OnboardingBuildMarkerInstruction> BuildSceneMarkerInstructionsFromSources(
|
|
IReadOnlyList<SceneManuscriptSourceRecord> sources,
|
|
IReadOnlyList<OnboardingBuildSceneMap> mappings)
|
|
{
|
|
var sourcesBySceneId = sources
|
|
.Where(source => source.WordStartParagraphIndex.HasValue)
|
|
.ToDictionary(source => source.SceneID);
|
|
return mappings
|
|
.Where(mapping => sourcesBySceneId.ContainsKey(mapping.SceneID))
|
|
.Select(mapping =>
|
|
{
|
|
var source = sourcesBySceneId[mapping.SceneID];
|
|
return new OnboardingBuildMarkerInstruction
|
|
{
|
|
TemporaryKey = mapping.TemporarySceneKey,
|
|
EntityID = mapping.SceneID,
|
|
WordParagraphIndex = source.WordStartParagraphIndex!.Value,
|
|
MarkerTag = $"PD-SCENE-{mapping.SceneID}",
|
|
MarkerTitle = "PlotDirector Scene",
|
|
MarkerPrefix = "PD-SCENE"
|
|
};
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
private static IReadOnlyList<OnboardingSceneStructureSummary> BuildSceneStructureSummary(
|
|
IReadOnlyList<ManuscriptScanChapterReviewDecision> chapters,
|
|
IReadOnlyList<ManuscriptStructureSceneBoundary> scenes)
|
|
=> chapters
|
|
.OrderBy(chapter => chapter.ChapterNumber)
|
|
.Select(chapter =>
|
|
{
|
|
var chapterScenes = scenes
|
|
.Where(scene => string.Equals(scene.TemporaryChapterKey, chapter.TemporaryChapterKey, StringComparison.Ordinal))
|
|
.OrderBy(scene => scene.SceneNumberWithinChapter)
|
|
.ToList();
|
|
return new OnboardingSceneStructureSummary
|
|
{
|
|
TemporaryChapterKey = chapter.TemporaryChapterKey,
|
|
ChapterNumber = chapter.ChapterNumber,
|
|
ChapterTitle = chapter.Title,
|
|
SceneCount = chapterScenes.Count,
|
|
WordCount = chapterScenes.Sum(scene => scene.WordCount),
|
|
Scenes = chapterScenes.Select(scene => new OnboardingSceneStructureDetail
|
|
{
|
|
SceneNumberWithinChapter = scene.SceneNumberWithinChapter,
|
|
Title = scene.Title,
|
|
WordCount = scene.WordCount,
|
|
StructuralSummary = scene.StructuralSummary,
|
|
StartParagraph = scene.StartParagraph,
|
|
EndParagraph = scene.EndParagraph
|
|
}).ToList()
|
|
};
|
|
})
|
|
.ToList();
|
|
|
|
private static IReadOnlyList<OnboardingSceneStructureSummary> BuildSceneStructureSummaryFromSources(
|
|
IReadOnlyList<ManuscriptScanChapterReviewDecision> chapters,
|
|
IReadOnlyList<SceneManuscriptSourceRecord> sources)
|
|
{
|
|
var byChapter = sources
|
|
.GroupBy(source => source.ChapterID)
|
|
.OrderBy(group => group.Min(source => source.SourceStartParagraph ?? int.MaxValue))
|
|
.ThenBy(group => group.Key)
|
|
.ToList();
|
|
return chapters
|
|
.OrderBy(chapter => chapter.ChapterNumber)
|
|
.Select((chapter, index) =>
|
|
{
|
|
var chapterSources = byChapter.ElementAtOrDefault(index)?
|
|
.OrderBy(source => source.SourceStartParagraph ?? int.MaxValue)
|
|
.ThenBy(source => source.SceneID)
|
|
.ToList() ?? [];
|
|
return new OnboardingSceneStructureSummary
|
|
{
|
|
TemporaryChapterKey = chapter.TemporaryChapterKey,
|
|
ChapterNumber = chapter.ChapterNumber,
|
|
ChapterTitle = chapter.Title,
|
|
SceneCount = chapterSources.Count,
|
|
WordCount = chapterSources.Sum(scene => scene.SourceWordCount),
|
|
Scenes = chapterSources.Select((scene, sceneIndex) => new OnboardingSceneStructureDetail
|
|
{
|
|
SceneNumberWithinChapter = sceneIndex + 1,
|
|
Title = $"Scene {sceneIndex + 1:N0}",
|
|
WordCount = scene.SourceWordCount,
|
|
StructuralSummary = scene.StructuralSummary,
|
|
StartParagraph = scene.SourceStartParagraph ?? 0,
|
|
EndParagraph = scene.SourceEndParagraph ?? 0
|
|
}).ToList()
|
|
};
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
private async Task SaveStructuralChapterSourcesAsync(ManuscriptScanPreview preview, OnboardingManuscriptBuildResult result)
|
|
{
|
|
var chapterIds = result.ChapterMappings.ToDictionary(item => item.TemporaryChapterKey, item => item.ChapterID, StringComparer.Ordinal);
|
|
foreach (var chapter in preview.Chapters)
|
|
{
|
|
if (!chapterIds.TryGetValue(chapter.TemporaryChapterKey, out var chapterId)
|
|
|| string.IsNullOrWhiteSpace(chapter.ChapterText))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
await builds.SaveStructuralChapterSourceAsync(new StructuralChapterSourceSaveRequest
|
|
{
|
|
ChapterID = chapterId,
|
|
ProjectID = preview.ProjectID,
|
|
BookID = preview.BookID,
|
|
SourceText = chapter.ChapterText,
|
|
SourceWordCount = CountWords(chapter.ChapterText),
|
|
SourceCharacterCount = chapter.ChapterText.Length,
|
|
ParagraphMapJson = JsonSerializer.Serialize(chapter.ParagraphMap)
|
|
});
|
|
}
|
|
}
|
|
|
|
private async Task SaveSceneManuscriptSourcesAsync(
|
|
ManuscriptScanPreview preview,
|
|
IReadOnlyList<ManuscriptStructureSceneBoundary> structuralScenes,
|
|
OnboardingManuscriptBuildResult result)
|
|
{
|
|
var sceneIds = result.SceneMappings.ToDictionary(item => item.TemporarySceneKey, item => item.SceneID, StringComparer.Ordinal);
|
|
var chapterIds = result.ChapterMappings.ToDictionary(item => item.TemporaryChapterKey, item => item.ChapterID, StringComparer.Ordinal);
|
|
foreach (var scene in structuralScenes)
|
|
{
|
|
if (!sceneIds.TryGetValue(scene.TemporarySceneKey, out var sceneId)
|
|
|| !chapterIds.TryGetValue(scene.TemporaryChapterKey, out var chapterId))
|
|
{
|
|
throw new InvalidOperationException("The imported scene source could not be matched to a canonical PlotDirector scene.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(scene.SourceText))
|
|
{
|
|
throw new InvalidOperationException($"Scene {scene.SceneNumberWithinChapter:N0} source text could not be persisted because the structural source text is empty.");
|
|
}
|
|
|
|
await builds.SaveSceneManuscriptSourceAsync(new SceneManuscriptSourceSaveRequest
|
|
{
|
|
SceneID = sceneId,
|
|
ProjectID = preview.ProjectID,
|
|
BookID = preview.BookID,
|
|
ChapterID = chapterId,
|
|
SourceText = scene.SourceText,
|
|
SourceWordCount = CountWords(scene.SourceText),
|
|
SourceCharacterCount = scene.SourceText.Length,
|
|
SourceStartParagraph = scene.StartParagraph,
|
|
SourceEndParagraph = scene.EndParagraph,
|
|
WordStartParagraphIndex = scene.WordStartPosition,
|
|
WordEndParagraphIndex = scene.WordEndPosition
|
|
});
|
|
|
|
if (!string.IsNullOrWhiteSpace(scene.StructuralSummary))
|
|
{
|
|
await builds.SaveSceneStructuralSummaryAsync(sceneId, scene.StructuralSummary);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task PublishBuildProgress(int userId, Guid previewId, string message, int percent, Func<OnboardingBuildProgress, Task>? publish, OnboardingManuscriptBuildResult? result = null)
|
|
{
|
|
var state = await scanPreviews.BuildProgressAsync(userId, previewId, message, percent, result);
|
|
if (publish is not null)
|
|
{
|
|
await publish(state);
|
|
}
|
|
}
|
|
|
|
private async Task<(int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList<Character> ExistingCharacters)?> GetScanReviewContextAsync(Guid? previewId)
|
|
{
|
|
var userId = RequireUserId();
|
|
var state = await onboarding.GetAsync(userId);
|
|
if (state is null || !IsMicrosoftWordState(state))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var preview = previewId.HasValue
|
|
? await scanPreviews.GetPreviewAsync(userId, previewId.Value)
|
|
: await scanPreviews.GetLatestPreviewAsync(userId, state.UserOnboardingStateID);
|
|
if (preview is null
|
|
|| preview.OnboardingID != state.UserOnboardingStateID
|
|
|| preview.ProjectID != state.ProjectID
|
|
|| preview.BookID != state.BookID)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var project = await projects.GetForUserAsync(preview.ProjectID, userId);
|
|
var book = await books.GetAsync(preview.BookID);
|
|
if (project is null || !project.IsOwner || project.IsArchived || book is null || book.ProjectID != project.ProjectID || book.IsArchived)
|
|
{
|
|
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,
|
|
AliasTargetTemporaryCharacterKey = decision?.AliasTargetTemporaryCharacterKey,
|
|
AliasTargetName = !string.IsNullOrWhiteSpace(decision?.AliasTargetTemporaryCharacterKey)
|
|
&& characterReview.TryGetValue(decision.AliasTargetTemporaryCharacterKey, out var targetDecision)
|
|
? targetDecision.Name
|
|
: null,
|
|
SexHint = CharacterSexHintService.Normalise(decision?.SexHint, reviewName)
|
|
};
|
|
})
|
|
.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();
|
|
var selectedChapterWordCount = chapters
|
|
.Where(chapter => chapter.Include)
|
|
.Sum(chapter => chapter.WordCount);
|
|
|
|
return new ManuscriptScanReviewViewModel
|
|
{
|
|
PreviewID = preview.PreviewID,
|
|
ReviewStatus = review.Status,
|
|
DocumentTitle = string.IsNullOrWhiteSpace(preview.DocumentTitle) ? "Word manuscript" : preview.DocumentTitle!,
|
|
SelectedProjectName = project.ProjectName,
|
|
SelectedBookTitle = BookOptionTitle(book),
|
|
TotalWordCount = preview.TotalWordCount,
|
|
ChapterCount = preview.ChapterCount,
|
|
SceneCount = preview.SceneCount,
|
|
CharacterCandidateCount = preview.CharacterCandidateCount,
|
|
SelectedChapterCount = selectedChapterKeys.Count,
|
|
SelectedSceneCount = selectedScenes.Count,
|
|
SelectedCharacterCount = characters.Count(character => character.Include && !character.IsAlias && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)),
|
|
SelectedWordCount = selectedChapterWordCount,
|
|
Chapters = chapters,
|
|
CharacterCandidates = characters,
|
|
ExistingCharacterOptions = existingOptions
|
|
};
|
|
}
|
|
|
|
private static void ValidateScanReview(ManuscriptScanReviewDecision review, ManuscriptScanPreview preview)
|
|
{
|
|
ValidateScanStructure(review, preview);
|
|
|
|
var duplicateName = review.Characters
|
|
.Where(IsIncludedCanonicalCharacter)
|
|
.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.");
|
|
}
|
|
|
|
var characterLookup = review.Characters.ToDictionary(character => character.TemporaryCharacterKey, StringComparer.Ordinal);
|
|
foreach (var character in review.Characters.Where(character => character.Include && !string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey)))
|
|
{
|
|
if (string.Equals(character.TemporaryCharacterKey, character.AliasTargetTemporaryCharacterKey, StringComparison.Ordinal))
|
|
{
|
|
throw new InvalidOperationException("A character candidate cannot be an alias of itself.");
|
|
}
|
|
|
|
if (!characterLookup.TryGetValue(character.AliasTargetTemporaryCharacterKey!, out var target)
|
|
|| !IsIncludedCanonicalCharacter(target))
|
|
{
|
|
throw new InvalidOperationException($"Choose an included primary character for the alias \"{character.Name}\".");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void ValidateScanStructure(ManuscriptScanReviewDecision review, ManuscriptScanPreview preview)
|
|
{
|
|
var previewChapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
|
|
var includedChapterKeys = review.Chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
|
|
if (includedChapterKeys.Count == 0)
|
|
{
|
|
throw new InvalidOperationException("Choose at least one chapter before continuing.");
|
|
}
|
|
|
|
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 chapter in review.Chapters)
|
|
{
|
|
if (!previewChapters.ContainsKey(chapter.TemporaryChapterKey))
|
|
{
|
|
throw new InvalidOperationException("One of the selected chapters no longer exists in the scan preview.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task AddScanCharacterAliasesAsync(ManuscriptScanReviewDecision review, OnboardingManuscriptBuildResult result)
|
|
{
|
|
var characterIds = result.CharacterMappings.ToDictionary(item => item.TemporaryCharacterKey, item => item.CharacterID, StringComparer.Ordinal);
|
|
var decisions = review.Characters.ToDictionary(item => item.TemporaryCharacterKey, StringComparer.Ordinal);
|
|
foreach (var alias in review.Characters.Where(character => character.Include && !string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey)))
|
|
{
|
|
if (!decisions.TryGetValue(alias.AliasTargetTemporaryCharacterKey!, out var target)
|
|
|| !characterIds.TryGetValue(target.TemporaryCharacterKey, out var characterId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var cleanAlias = Clean(alias.Name);
|
|
if (string.IsNullOrWhiteSpace(cleanAlias) || string.Equals(cleanAlias, target.Name, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var existingAliases = await characters.ListAliasesAsync(characterId);
|
|
if (existingAliases.Any(item => string.Equals(item.Alias, cleanAlias, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
await characters.AddAliasAsync(characterId, cleanAlias, null);
|
|
}
|
|
}
|
|
|
|
private static bool IsIncludedCanonicalCharacter(ManuscriptScanCharacterReviewDecision character)
|
|
=> character.Include
|
|
&& string.IsNullOrWhiteSpace(character.AliasTargetTemporaryCharacterKey)
|
|
&& !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase);
|
|
|
|
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()
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var state = await onboarding.GetAsync(currentUser.UserId.Value);
|
|
return state is null || state.OnboardingCompletedUtc is null;
|
|
}
|
|
|
|
public async Task<OnboardingNudgeViewModel> GetDashboardNudgeAsync()
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
return new OnboardingNudgeViewModel();
|
|
}
|
|
|
|
var state = await onboarding.GetAsync(currentUser.UserId.Value);
|
|
if (state is not null && IsMicrosoftWordState(state))
|
|
{
|
|
var status = await companionPresence.GetStatusAsync(currentUser.UserId.Value);
|
|
return new OnboardingNudgeViewModel
|
|
{
|
|
ShouldShow = true,
|
|
IsCompanionPresence = true,
|
|
CompanionConnected = status.IsConnected,
|
|
Title = status.IsConnected ? "Word Companion Connected" : "Connect your Word Companion",
|
|
Description = status.IsConnected
|
|
? "Your Word Companion is ready for this story setup."
|
|
: "Open Word, start the Companion, and sign in with your PlotDirector account.",
|
|
ButtonText = status.IsConnected ? "View setup" : "Connect Companion"
|
|
};
|
|
}
|
|
|
|
if (state?.OnboardingCompletedUtc is not null)
|
|
{
|
|
return new OnboardingNudgeViewModel { ShouldShow = false };
|
|
}
|
|
|
|
var hasStarted = state is not null
|
|
&& (!string.IsNullOrWhiteSpace(state.WritingJourney)
|
|
|| !string.IsNullOrWhiteSpace(state.WritingSoftware)
|
|
|| state.ProjectID.HasValue
|
|
|| state.BookID.HasValue
|
|
|| state.CurrentStep != OnboardingSteps.Welcome);
|
|
|
|
return hasStarted
|
|
? new OnboardingNudgeViewModel
|
|
{
|
|
ShouldShow = true,
|
|
Title = "Continue your PlotDirector setup",
|
|
Description = "Pick the project and book PlotDirector should use for your first guided setup.",
|
|
ButtonText = "Continue setup"
|
|
}
|
|
: new OnboardingNudgeViewModel { ShouldShow = true };
|
|
}
|
|
|
|
public async Task<bool> ShouldShowWordCompanionHeaderAsync()
|
|
{
|
|
if (!currentUser.UserId.HasValue)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var state = await onboarding.GetAsync(currentUser.UserId.Value);
|
|
return state is not null && IsMicrosoftWordState(state);
|
|
}
|
|
|
|
public async Task MoveToNextStepAsync()
|
|
{
|
|
var state = await GetOrCreateAsync();
|
|
var nextStep = ResolveStep(state) switch
|
|
{
|
|
OnboardingSteps.Welcome => OnboardingSteps.WritingJourney,
|
|
OnboardingSteps.WritingJourney => OnboardingSteps.WritingSoftware,
|
|
OnboardingSteps.WritingSoftware => OnboardingSteps.Project,
|
|
OnboardingSteps.Project => OnboardingSteps.Book,
|
|
OnboardingSteps.Book => OnboardingSteps.NextPathPreview,
|
|
_ => OnboardingSteps.NextPathPreview
|
|
};
|
|
|
|
await onboarding.SetCurrentStepAsync(RequireUserId(), nextStep);
|
|
}
|
|
|
|
public async Task StartManualSetupAsync()
|
|
{
|
|
var userId = RequireUserId();
|
|
await onboarding.SaveWritingJourneyAsync(userId, WritingJourneyValues.PlanningNewStory);
|
|
var state = await onboarding.SaveWritingSoftwareAsync(userId, WritingSoftwareValues.Other);
|
|
await scanPreviews.ClearAsync(userId, state.UserOnboardingStateID);
|
|
await onboarding.SetCurrentStepAsync(userId, OnboardingSteps.Project);
|
|
}
|
|
|
|
public async Task StartManuscriptImportSetupAsync()
|
|
{
|
|
var userId = RequireUserId();
|
|
var state = await onboarding.SaveWritingJourneyAsync(userId, WritingJourneyValues.ManuscriptMostlyComplete);
|
|
state = await onboarding.ClearWritingSoftwareAsync(userId);
|
|
await scanPreviews.ClearAsync(userId, state.UserOnboardingStateID);
|
|
await onboarding.SetCurrentStepAsync(userId, OnboardingSteps.WritingSoftware);
|
|
}
|
|
|
|
public async Task BeginManuscriptImportForBookAsync(int projectId, int bookId)
|
|
{
|
|
var userId = RequireUserId();
|
|
var project = await projects.GetForUserAsync(projectId, userId);
|
|
var book = await books.GetAsync(bookId);
|
|
if (project is null || !project.IsOwner || project.IsArchived || book is null || book.ProjectID != project.ProjectID || book.IsArchived)
|
|
{
|
|
throw new InvalidOperationException("Choose one of your active books before importing a manuscript.");
|
|
}
|
|
|
|
await onboarding.SaveWritingJourneyAsync(userId, WritingJourneyValues.ManuscriptMostlyComplete);
|
|
await onboarding.SaveWritingSoftwareAsync(userId, WritingSoftwareValues.MicrosoftWord);
|
|
await onboarding.SaveProjectAsync(userId, project.ProjectID);
|
|
var state = await onboarding.SaveBookAsync(userId, book.BookID);
|
|
await scanPreviews.ClearAsync(userId, state.UserOnboardingStateID);
|
|
await onboarding.SetCurrentStepAsync(userId, OnboardingSteps.NextPathPreview);
|
|
}
|
|
|
|
public async Task SetCurrentStepAsync(string currentStep)
|
|
{
|
|
if (currentStep is not (OnboardingSteps.Welcome
|
|
or OnboardingSteps.WritingJourney
|
|
or OnboardingSteps.WritingSoftware
|
|
or OnboardingSteps.Project
|
|
or OnboardingSteps.Book
|
|
or OnboardingSteps.NextPathPreview
|
|
or OnboardingSteps.Complete))
|
|
{
|
|
throw new InvalidOperationException("Choose a valid onboarding step.");
|
|
}
|
|
|
|
await onboarding.SetCurrentStepAsync(RequireUserId(), currentStep);
|
|
}
|
|
|
|
public async Task SaveWritingJourneyAsync(string writingJourney)
|
|
{
|
|
if (!WritingJourneys.Contains(writingJourney))
|
|
{
|
|
throw new InvalidOperationException("Choose a writing journey option.");
|
|
}
|
|
|
|
await onboarding.SaveWritingJourneyAsync(RequireUserId(), writingJourney);
|
|
}
|
|
|
|
public async Task SaveWritingSoftwareAsync(string writingSoftware)
|
|
{
|
|
if (!WritingSoftware.Contains(writingSoftware))
|
|
{
|
|
throw new InvalidOperationException("Choose a writing software option.");
|
|
}
|
|
|
|
await onboarding.SaveWritingSoftwareAsync(RequireUserId(), writingSoftware);
|
|
}
|
|
|
|
public async Task SaveProjectAsync(ProjectOnboardingForm form)
|
|
{
|
|
var userId = RequireUserId();
|
|
if (string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal))
|
|
{
|
|
var projectId = form.SelectedProjectID.GetValueOrDefault();
|
|
var project = projectId > 0 ? await projects.GetForUserAsync(projectId, userId) : null;
|
|
if (project is null || !project.IsOwner || project.IsArchived)
|
|
{
|
|
throw new InvalidOperationException("Choose one of your active projects.");
|
|
}
|
|
|
|
await onboarding.SaveProjectAsync(userId, project.ProjectID);
|
|
return;
|
|
}
|
|
|
|
if (!string.Equals(form.Mode, OnboardingSelectionModes.Create, StringComparison.Ordinal))
|
|
{
|
|
throw new InvalidOperationException("Choose whether to create or use an existing project.");
|
|
}
|
|
|
|
var projectName = Clean(form.ProjectName);
|
|
if (string.IsNullOrWhiteSpace(projectName))
|
|
{
|
|
throw new InvalidOperationException("Enter a project name.");
|
|
}
|
|
|
|
var projectIdCreated = await projects.SaveAsync(new Project
|
|
{
|
|
ProjectName = projectName,
|
|
Description = CleanOptional(form.Description)
|
|
}, userId, "Custom");
|
|
await activity.RecordAsync(projectIdCreated, "Created", "Project", projectIdCreated, projectName);
|
|
await onboarding.SaveProjectAsync(userId, projectIdCreated);
|
|
}
|
|
|
|
public async Task SaveBookAsync(BookOnboardingForm form)
|
|
{
|
|
var userId = RequireUserId();
|
|
var state = await GetOrCreateAsync();
|
|
var projectId = state.ProjectID.GetValueOrDefault();
|
|
var project = projectId > 0 ? await projects.GetForUserAsync(projectId, userId) : null;
|
|
if (project is null || !project.IsOwner || project.IsArchived)
|
|
{
|
|
throw new InvalidOperationException("Choose a project before choosing a book.");
|
|
}
|
|
|
|
if (string.Equals(form.Mode, OnboardingSelectionModes.Select, StringComparison.Ordinal))
|
|
{
|
|
var bookId = form.SelectedBookID.GetValueOrDefault();
|
|
var book = bookId > 0 ? await books.GetAsync(bookId) : null;
|
|
if (book is null || book.ProjectID != project.ProjectID || book.IsArchived)
|
|
{
|
|
throw new InvalidOperationException("Choose a book from the selected project.");
|
|
}
|
|
|
|
await onboarding.SaveBookAsync(userId, book.BookID);
|
|
return;
|
|
}
|
|
|
|
if (!string.Equals(form.Mode, OnboardingSelectionModes.Create, StringComparison.Ordinal))
|
|
{
|
|
throw new InvalidOperationException("Choose whether to create or use an existing book.");
|
|
}
|
|
|
|
var bookTitle = Clean(form.BookTitle);
|
|
if (string.IsNullOrWhiteSpace(bookTitle))
|
|
{
|
|
throw new InvalidOperationException("Enter a book title.");
|
|
}
|
|
|
|
var createModel = await bookService.GetCreateAsync(project.ProjectID)
|
|
?? throw new InvalidOperationException("The selected project could not be used.");
|
|
createModel.BookTitle = bookTitle;
|
|
createModel.Subtitle = CleanOptional(form.Subtitle);
|
|
createModel.TargetWordCount = form.TargetWordCount;
|
|
var result = await bookService.SaveAsync(createModel);
|
|
if (!result.Succeeded)
|
|
{
|
|
throw new InvalidOperationException(result.ErrorMessage ?? "The book could not be created.");
|
|
}
|
|
|
|
await onboarding.SaveBookAsync(userId, result.BookID);
|
|
}
|
|
|
|
public async Task MarkCompletedAsync()
|
|
=> await onboarding.MarkCompletedAsync(RequireUserId());
|
|
|
|
private int RequireUserId()
|
|
=> currentUser.UserId ?? throw new InvalidOperationException("Sign in to use onboarding.");
|
|
|
|
private static string ResolveStep(UserOnboardingState state)
|
|
{
|
|
if (state.OnboardingCompletedUtc.HasValue)
|
|
{
|
|
return OnboardingSteps.NextPathPreview;
|
|
}
|
|
|
|
if (state.CurrentStep is OnboardingSteps.Welcome
|
|
or OnboardingSteps.WritingJourney
|
|
or OnboardingSteps.WritingSoftware
|
|
or OnboardingSteps.Project
|
|
or OnboardingSteps.Book
|
|
or OnboardingSteps.NextPathPreview)
|
|
{
|
|
return state.CurrentStep;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(state.WritingJourney))
|
|
{
|
|
return OnboardingSteps.WritingJourney;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(state.WritingSoftware))
|
|
{
|
|
return OnboardingSteps.WritingSoftware;
|
|
}
|
|
|
|
if (!state.ProjectID.HasValue)
|
|
{
|
|
return OnboardingSteps.Project;
|
|
}
|
|
|
|
if (!state.BookID.HasValue)
|
|
{
|
|
return OnboardingSteps.Book;
|
|
}
|
|
|
|
return OnboardingSteps.NextPathPreview;
|
|
}
|
|
|
|
private static int StepNumber(string step) => step switch
|
|
{
|
|
OnboardingSteps.Welcome => 1,
|
|
OnboardingSteps.WritingJourney => 2,
|
|
OnboardingSteps.WritingSoftware => 3,
|
|
OnboardingSteps.Project => 4,
|
|
OnboardingSteps.Book => 5,
|
|
_ => 6
|
|
};
|
|
|
|
private async Task<IReadOnlyList<OnboardingProjectOptionViewModel>> BuildProjectOptionsAsync()
|
|
{
|
|
var userId = RequireUserId();
|
|
var rows = await projects.ListActiveForUserAsync(userId);
|
|
return rows
|
|
.Where(project => project.IsOwner && !project.IsArchived)
|
|
.OrderBy(project => project.ProjectName)
|
|
.Select(project => new OnboardingProjectOptionViewModel
|
|
{
|
|
ProjectID = project.ProjectID,
|
|
ProjectName = project.ProjectName,
|
|
Description = string.IsNullOrWhiteSpace(project.Description)
|
|
? $"Updated {project.UpdatedDate:yyyy-MM-dd}"
|
|
: project.Description
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
private async Task<IReadOnlyList<OnboardingBookOptionViewModel>> BuildBookOptionsAsync(int projectId)
|
|
{
|
|
var project = await projects.GetForUserAsync(projectId, RequireUserId());
|
|
if (project is null || !project.IsOwner || project.IsArchived)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var rows = await books.ListByProjectAsync(projectId);
|
|
return rows
|
|
.Where(book => !book.IsArchived)
|
|
.OrderBy(book => book.SortOrder)
|
|
.ThenBy(book => book.BookNumber)
|
|
.ThenBy(book => book.BookTitle)
|
|
.Select(book => new OnboardingBookOptionViewModel
|
|
{
|
|
BookID = book.BookID,
|
|
DisplayTitle = BookOptionTitle(book),
|
|
Detail = BookOptionDetail(book)
|
|
})
|
|
.ToList();
|
|
}
|
|
|
|
private static string BookOptionTitle(Book book)
|
|
=> $"Book {book.BookNumber}: {book.BookDisplayTitle}";
|
|
|
|
private static string BookOptionDetail(Book book)
|
|
{
|
|
var parts = new List<string> { book.Status };
|
|
if (book.TargetWordCount is > 0)
|
|
{
|
|
parts.Add($"{book.TargetWordCount.Value:N0} target words");
|
|
}
|
|
|
|
parts.Add($"Updated {book.UpdatedDate:yyyy-MM-dd}");
|
|
return string.Join(" / ", parts);
|
|
}
|
|
|
|
private static string Clean(string? value) => (value ?? string.Empty).Trim();
|
|
|
|
private static int CountWords(string value)
|
|
=> string.IsNullOrWhiteSpace(value)
|
|
? 0
|
|
: value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
|
|
|
|
private static bool IsMicrosoftWordState(UserOnboardingState state)
|
|
=> string.Equals(state.WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal)
|
|
|| state.UsesWordCompanion == true;
|
|
|
|
private static CompanionPresenceViewModel ToPresenceViewModel(WordCompanionPresenceStatus status)
|
|
=> new()
|
|
{
|
|
IsConnected = status.IsConnected,
|
|
DocumentOpen = status.DocumentOpen,
|
|
Status = status.Status,
|
|
CompanionVersion = status.CompanionVersion,
|
|
CurrentDocumentName = status.CurrentDocumentName,
|
|
LastHeartbeatUtc = status.LastHeartbeatUtc
|
|
};
|
|
|
|
private static ManuscriptScanStateViewModel ToScanStateViewModel(ManuscriptScanState state)
|
|
=> new()
|
|
{
|
|
Status = state.Status,
|
|
ReviewStatus = state.ReviewStatus,
|
|
Message = state.Message,
|
|
PercentComplete = state.PercentComplete,
|
|
ChapterCount = state.ChapterCount,
|
|
SceneCount = state.SceneCount,
|
|
CharacterCandidateCount = state.CharacterCandidateCount,
|
|
TotalWordCount = state.TotalWordCount,
|
|
PreviewID = state.PreviewID
|
|
};
|
|
|
|
private static int CandidateCategorySortValue(string? category)
|
|
=> string.Equals(category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase) ? 4
|
|
: string.Equals(category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase) ? 3
|
|
: string.Equals(category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase) ? 2
|
|
: 1;
|
|
|
|
private static string? CleanOptional(string? value)
|
|
{
|
|
var cleaned = Clean(value);
|
|
return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned;
|
|
}
|
|
|
|
private static IReadOnlyList<OnboardingOptionViewModel> WritingJourneyOptions() =>
|
|
[
|
|
new()
|
|
{
|
|
Value = WritingJourneyValues.PlanningNewStory,
|
|
Title = "I am planning a brand new story",
|
|
Description = "You are shaping the idea, structure, characters, or world before drafting."
|
|
},
|
|
new()
|
|
{
|
|
Value = WritingJourneyValues.AlreadyStarted,
|
|
Title = "I have already started writing",
|
|
Description = "You have draft material and want PlotDirector to help you organise what exists."
|
|
},
|
|
new()
|
|
{
|
|
Value = WritingJourneyValues.ManuscriptMostlyComplete,
|
|
Title = "My manuscript is largely complete",
|
|
Description = "You are ready to review structure, continuity, and story detail across the manuscript."
|
|
}
|
|
];
|
|
|
|
private static IReadOnlyList<OnboardingOptionViewModel> WritingSoftwareOptions() =>
|
|
[
|
|
new()
|
|
{
|
|
Value = WritingSoftwareValues.MicrosoftWord,
|
|
Title = "Microsoft Word",
|
|
Description = "Best fit for the PlotDirector Word Companion workflow.",
|
|
Badge = "Recommended"
|
|
},
|
|
new()
|
|
{
|
|
Value = WritingSoftwareValues.GoogleDocs,
|
|
Title = "Google Docs",
|
|
Description = "Use exports later when manuscript import is added."
|
|
},
|
|
new()
|
|
{
|
|
Value = WritingSoftwareValues.Scrivener,
|
|
Title = "Scrivener",
|
|
Description = "Use a manuscript export later when import support arrives."
|
|
},
|
|
new()
|
|
{
|
|
Value = WritingSoftwareValues.LibreOfficeOpenOffice,
|
|
Title = "LibreOffice / OpenOffice",
|
|
Description = "Good for document-based workflows outside Microsoft Word."
|
|
},
|
|
new()
|
|
{
|
|
Value = WritingSoftwareValues.MarkdownObsidian,
|
|
Title = "Markdown / Obsidian",
|
|
Description = "For plain-text writing systems and linked-note workflows."
|
|
},
|
|
new()
|
|
{
|
|
Value = WritingSoftwareValues.Other,
|
|
Title = "Other",
|
|
Description = "PlotDirector can still tailor future setup around your tools."
|
|
}
|
|
];
|
|
}
|