using System.Text.Json; using PlotLine.Data; using PlotLine.Models; using PlotLine.ViewModels; namespace PlotLine.Services; public interface ICoreImportBasicLocationService { Task EnsurePreparedAsync(int userId, Guid previewId, int projectId, int bookId, int? buildId, IReadOnlyList sceneSources); Task GetReviewAsync(int userId, Guid previewId, string documentTitle, string projectName, string bookTitle); Task IsCompleteAsync(int userId, Guid previewId); Task SaveDecisionsAsync(int userId, CoreImportBasicLocationReviewForm form); Task CompleteAsync(int userId, Guid previewId); Task SkipAsync(int userId, Guid previewId); Task> GetProtectedLocationNamesAsync(int userId, Guid previewId); } public sealed class CoreImportBasicLocationService( ICoreImportBasicLocationReviewRepository reviews, ILocationRepository locations) : ICoreImportBasicLocationService { public async Task EnsurePreparedAsync( int userId, Guid previewId, int projectId, int bookId, int? buildId, IReadOnlyList 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 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 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(StringComparer.Ordinal); var resolving = new HashSet(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 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> GetProtectedLocationNamesAsync(int userId, Guid previewId) { var review = await reviews.GetByPreviewAsync(userId, previewId); if (review is null || !IsComplete(review.Status)) { return new HashSet(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 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 ReadKnownNames(CoreImportBasicLocationCandidate candidate) { try { return JsonSerializer.Deserialize>(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(); }