324 lines
15 KiB
C#
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();
|
|
}
|