PlotDirector/PlotLine/Services/CoreImportBasicLocationService.cs

324 lines
15 KiB
C#

using System.Text.Json;
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface ICoreImportBasicLocationService
{
Task<CoreImportBasicLocationReview?> EnsurePreparedAsync(int userId, Guid previewId, int projectId, int bookId, int? buildId, IReadOnlyList<SceneManuscriptSourceRecord> sceneSources);
Task<CoreImportBasicLocationReview?> ResetAndPrepareAsync(int userId, Guid previewId, int projectId, int bookId, int? buildId, IReadOnlyList<SceneManuscriptSourceRecord> sceneSources);
Task<CoreImportBasicLocationReviewViewModel?> GetReviewAsync(int userId, Guid previewId, string documentTitle, string projectName, string bookTitle);
Task<bool> IsCompleteAsync(int userId, Guid previewId);
Task SaveDecisionsAsync(int userId, CoreImportBasicLocationReviewForm form);
Task CompleteAsync(int userId, Guid previewId);
Task SkipAsync(int userId, Guid previewId);
Task<IReadOnlySet<string>> GetProtectedLocationNamesAsync(int userId, Guid previewId);
}
public sealed class CoreImportBasicLocationService(
ICoreImportBasicLocationReviewRepository reviews,
ILocationRepository locations) : ICoreImportBasicLocationService
{
public async Task<CoreImportBasicLocationReview?> EnsurePreparedAsync(
int userId,
Guid previewId,
int projectId,
int bookId,
int? buildId,
IReadOnlyList<SceneManuscriptSourceRecord> sceneSources)
{
var review = await reviews.CreateAsync(userId, previewId, projectId, bookId, buildId);
var existing = await reviews.ListCandidatesAsync(review.CoreImportBasicLocationReviewID);
if (existing.Candidates.Count > 0 || IsComplete(review.Status))
{
return review;
}
var existingLocations = await locations.ListByProjectAsync(projectId);
var existingAliases = await locations.ListAliasesByProjectAsync(projectId);
var existingNames = existingLocations
.Select(location => new CoreImportExistingLocationName(location.LocationID, location.LocationName))
.Concat(existingAliases.Select(alias => new CoreImportExistingLocationName(alias.LocationID, alias.Alias)))
.ToList();
var discovery = CoreImportBasicLocationDetector.Discover(sceneSources, existingNames);
foreach (var candidate in discovery.Candidates)
{
var saved = await reviews.SaveCandidateAsync(review.CoreImportBasicLocationReviewID, candidate);
await reviews.DeleteEvidenceAsync(saved.CoreImportBasicLocationCandidateID);
foreach (var evidence in candidate.Evidence)
{
await reviews.SaveEvidenceAsync(saved.CoreImportBasicLocationCandidateID, evidence);
}
}
await reviews.CompletePreparationAsync(review.CoreImportBasicLocationReviewID, discovery.RawObservationCount, discovery.Candidates.Count);
return await reviews.GetByPreviewAsync(userId, previewId);
}
public async Task<CoreImportBasicLocationReview?> ResetAndPrepareAsync(
int userId,
Guid previewId,
int projectId,
int bookId,
int? buildId,
IReadOnlyList<SceneManuscriptSourceRecord> sceneSources)
{
await reviews.ResetByPreviewAsync(userId, previewId);
return await EnsurePreparedAsync(userId, previewId, projectId, bookId, buildId, sceneSources);
}
public async Task<CoreImportBasicLocationReviewViewModel?> GetReviewAsync(int userId, Guid previewId, string documentTitle, string projectName, string bookTitle)
{
var review = await reviews.GetByPreviewAsync(userId, previewId);
if (review is null)
{
return null;
}
var (candidates, evidenceRows) = await reviews.ListCandidatesAsync(review.CoreImportBasicLocationReviewID);
var evidenceLookup = evidenceRows
.GroupBy(evidence => evidence.CoreImportBasicLocationCandidateID)
.ToDictionary(group => group.Key, group => group.OrderBy(item => item.SortOrder).ToList());
var existingLocations = await locations.ListByProjectAsync(review.ProjectID);
return new CoreImportBasicLocationReviewViewModel
{
PreviewID = previewId,
ReviewID = review.CoreImportBasicLocationReviewID,
Status = review.Status,
DocumentTitle = documentTitle,
SelectedProjectName = projectName,
SelectedBookTitle = bookTitle,
RawObservationCount = review.RawObservationCount,
CandidateCount = review.CandidateCount,
ExistingLocationOptions = existingLocations
.OrderBy(location => location.LocationPath)
.ThenBy(location => location.LocationName)
.Select(location => new CoreImportLocationOptionViewModel
{
LocationID = location.LocationID,
LocationName = location.LocationName,
LocationPath = location.LocationPath
})
.ToList(),
Candidates = candidates.Select(candidate => new CoreImportBasicLocationCandidateViewModel
{
StableKey = candidate.StableKey,
ProposedName = candidate.ProposedName,
PreferredName = candidate.PreferredName,
KnownNames = ReadKnownNames(candidate),
Action = candidate.Action,
ParentLocationID = candidate.ParentLocationID,
ParentCandidateKey = candidate.ParentCandidateKey,
SameAsLocationID = candidate.SameAsLocationID,
SameAsCandidateKey = candidate.SameAsCandidateKey,
ObservationCount = candidate.ObservationCount,
FirstChapterNumber = candidate.FirstChapterNumber,
FirstSceneNumber = candidate.FirstSceneNumber,
Evidence = evidenceLookup.GetValueOrDefault(candidate.CoreImportBasicLocationCandidateID, [])
.Select(evidence => new CoreImportBasicLocationEvidenceViewModel
{
ChapterNumber = evidence.ChapterNumber,
SceneNumber = evidence.SceneNumber,
EvidenceText = evidence.EvidenceText
})
.ToList()
}).ToList()
};
}
public async Task<bool> IsCompleteAsync(int userId, Guid previewId)
{
var review = await reviews.GetByPreviewAsync(userId, previewId);
return review is not null && IsComplete(review.Status);
}
public async Task SaveDecisionsAsync(int userId, CoreImportBasicLocationReviewForm form)
{
var review = await reviews.GetByPreviewAsync(userId, form.PreviewID)
?? throw new InvalidOperationException("The basic location review could not be found.");
foreach (var item in form.Locations)
{
var action = NormalizeAction(item.Action);
var decision = new CoreImportBasicLocationDecision
{
StableKey = item.StableKey,
PreferredName = Clean(item.PreferredName),
Action = action,
ParentLocationID = action == CoreImportBasicLocationCandidateActions.Ignore ? null : item.ParentLocationID,
ParentCandidateKey = action == CoreImportBasicLocationCandidateActions.Ignore ? null : CleanOptional(item.ParentCandidateKey),
SameAsLocationID = action == CoreImportBasicLocationCandidateActions.SameLocation ? item.SameAsLocationID : null,
SameAsCandidateKey = action == CoreImportBasicLocationCandidateActions.SameLocation ? CleanOptional(item.SameAsCandidateKey) : null
};
await reviews.SaveDecisionAsync(review.CoreImportBasicLocationReviewID, decision);
}
}
public async Task CompleteAsync(int userId, Guid previewId)
{
var review = await reviews.GetByPreviewAsync(userId, previewId)
?? throw new InvalidOperationException("The basic location review could not be found.");
var (candidates, _) = await reviews.ListCandidatesAsync(review.CoreImportBasicLocationReviewID);
var lookup = candidates.ToDictionary(candidate => candidate.StableKey, StringComparer.Ordinal);
var resolved = new Dictionary<string, int>(StringComparer.Ordinal);
var resolving = new HashSet<string>(StringComparer.Ordinal);
foreach (var candidate in candidates.OrderBy(candidate => candidate.SortOrder))
{
if (string.Equals(candidate.Action, CoreImportBasicLocationCandidateActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
continue;
}
await ResolveCandidateAsync(candidate.StableKey);
}
await reviews.MarkCompleteAsync(review.CoreImportBasicLocationReviewID);
async Task<int?> ResolveCandidateAsync(string stableKey)
{
if (resolved.TryGetValue(stableKey, out var existingResolved))
{
return existingResolved;
}
if (!lookup.TryGetValue(stableKey, out var candidate)
|| string.Equals(candidate.Action, CoreImportBasicLocationCandidateActions.Ignore, StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (!resolving.Add(stableKey))
{
throw new InvalidOperationException("Location candidate grouping contains a cycle. Review the 'Same location as' choices and try again.");
}
try
{
int locationId;
if (candidate.SameAsLocationID.HasValue)
{
locationId = candidate.SameAsLocationID.Value;
}
else if (!string.IsNullOrWhiteSpace(candidate.SameAsCandidateKey)
&& !string.Equals(candidate.SameAsCandidateKey, stableKey, StringComparison.Ordinal))
{
var sameAsLocation = await ResolveCandidateAsync(candidate.SameAsCandidateKey);
if (!sameAsLocation.HasValue)
{
throw new InvalidOperationException($"The location '{candidate.PreferredName}' points at a candidate that will not be created.");
}
locationId = sameAsLocation.Value;
}
else if (candidate.ExistingLocationID.HasValue)
{
locationId = candidate.ExistingLocationID.Value;
}
else
{
int? parentLocationId = candidate.ParentLocationID;
if (!parentLocationId.HasValue && !string.IsNullOrWhiteSpace(candidate.ParentCandidateKey))
{
parentLocationId = await ResolveCandidateAsync(candidate.ParentCandidateKey);
}
locationId = await locations.SaveAsync(new LocationItem
{
ProjectID = review.ProjectID,
ParentLocationID = parentLocationId,
LocationName = Clean(candidate.PreferredName),
ShowInQuickAddBar = false,
ExcludeFromCompanionDetection = false,
DetectionPriority = 50
});
}
await SyncAliasesAsync(locationId, ReadKnownNames(candidate).Append(candidate.PreferredName));
await reviews.SetResultAsync(review.CoreImportBasicLocationReviewID, stableKey, locationId);
resolved[stableKey] = locationId;
return locationId;
}
finally
{
resolving.Remove(stableKey);
}
}
}
public async Task SkipAsync(int userId, Guid previewId)
{
var review = await reviews.GetByPreviewAsync(userId, previewId)
?? throw new InvalidOperationException("The basic location review could not be found.");
await reviews.MarkSkippedAsync(review.CoreImportBasicLocationReviewID);
}
public async Task<IReadOnlySet<string>> GetProtectedLocationNamesAsync(int userId, Guid previewId)
{
var review = await reviews.GetByPreviewAsync(userId, previewId);
if (review is null || !IsComplete(review.Status))
{
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
}
var projectLocations = await locations.ListByProjectAsync(review.ProjectID);
var aliases = await locations.ListAliasesByProjectAsync(review.ProjectID);
var names = projectLocations
.Select(location => location.LocationName)
.Concat(aliases.Select(alias => alias.Alias))
.Select(CoreImportBasicLocationDetector.NormalizeName)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return names;
}
private async Task SyncAliasesAsync(int locationId, IEnumerable<string> names)
{
var existing = await locations.ListAliasesAsync(locationId);
var existingNames = existing.Select(alias => alias.Alias).ToHashSet(StringComparer.OrdinalIgnoreCase);
var sortOrder = existing.Select(alias => alias.SortOrder).DefaultIfEmpty(0).Max() + 10;
foreach (var name in names.Select(Clean).Where(name => !string.IsNullOrWhiteSpace(name)).Distinct(StringComparer.OrdinalIgnoreCase))
{
if (existingNames.Contains(name))
{
continue;
}
await locations.AddAliasAsync(locationId, name, sortOrder);
sortOrder += 10;
}
}
private static IReadOnlyList<string> ReadKnownNames(CoreImportBasicLocationCandidate candidate)
{
try
{
return JsonSerializer.Deserialize<List<string>>(candidate.KnownNamesJson) ?? [];
}
catch (JsonException)
{
return [candidate.ProposedName];
}
}
private static bool IsComplete(string status)
=> string.Equals(status, CoreImportBasicLocationReviewStatuses.Completed, StringComparison.OrdinalIgnoreCase)
|| string.Equals(status, CoreImportBasicLocationReviewStatuses.Skipped, StringComparison.OrdinalIgnoreCase);
private static string NormalizeAction(string? action)
=> string.Equals(action, CoreImportBasicLocationCandidateActions.SameLocation, StringComparison.OrdinalIgnoreCase)
? CoreImportBasicLocationCandidateActions.SameLocation
: string.Equals(action, CoreImportBasicLocationCandidateActions.Ignore, StringComparison.OrdinalIgnoreCase)
? CoreImportBasicLocationCandidateActions.Ignore
: CoreImportBasicLocationCandidateActions.Create;
private static string Clean(string? value)
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
private static string? CleanOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}