2441 lines
122 KiB
C#
2441 lines
122 KiB
C#
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Diagnostics;
|
|
using PlotLine.Data;
|
|
using PlotLine.Models;
|
|
using PlotLine.ViewModels;
|
|
|
|
namespace PlotLine.Services;
|
|
|
|
public interface IImportService
|
|
{
|
|
Task<ImportIndexViewModel> PreviewAsync(ImportIndexViewModel model);
|
|
Task<ImportResult> ImportAsync(ImportIndexViewModel model);
|
|
Task<string> BuildValidationReportAsync(ImportIndexViewModel model);
|
|
}
|
|
|
|
public sealed class ImportService(IImportRepository imports, ICurrentUserService currentUser, ILogger<ImportService> logger) : IImportService
|
|
{
|
|
private const int TitleMaxLength = 200;
|
|
private const int RelativeTimeMaxLength = 200;
|
|
private const int LargeTextWarningLength = 100_000;
|
|
private const int SuspiciousBookCount = 20;
|
|
private const int SuspiciousChapterCount = 250;
|
|
private const int SuspiciousSceneCount = 2_000;
|
|
|
|
private enum ProjectImportFormat
|
|
{
|
|
Unsupported,
|
|
LegacyPackage,
|
|
ProjectBackup
|
|
}
|
|
|
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
{
|
|
PropertyNameCaseInsensitive = true,
|
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
|
AllowTrailingCommas = true
|
|
};
|
|
|
|
private static readonly JsonSerializerOptions RestoreJsonOptions = new()
|
|
{
|
|
WriteIndented = false
|
|
};
|
|
|
|
public async Task<ImportIndexViewModel> PreviewAsync(ImportIndexViewModel model)
|
|
{
|
|
var timer = Stopwatch.StartNew();
|
|
model.JsonText = await ReadJsonTextAsync(model);
|
|
model.Preview = await BuildPreviewAsync(model.JsonText);
|
|
timer.Stop();
|
|
logger.LogInformation("JSON import preview completed in {ElapsedMilliseconds} ms with {ErrorCount} errors, {WarningCount} warnings, and {UnresolvedReferenceCount} unresolved references.",
|
|
timer.ElapsedMilliseconds,
|
|
model.Preview.ErrorCount,
|
|
model.Preview.WarningCount,
|
|
model.Preview.UnresolvedReferenceCount);
|
|
return model;
|
|
}
|
|
|
|
public async Task<ImportResult> ImportAsync(ImportIndexViewModel model)
|
|
{
|
|
var userId = currentUser.UserId;
|
|
if (!userId.HasValue)
|
|
{
|
|
return new ImportResult
|
|
{
|
|
Succeeded = false,
|
|
Message = "Sign in before importing a project."
|
|
};
|
|
}
|
|
|
|
model.JsonText = await ReadJsonTextAsync(model);
|
|
var formatValidation = new ImportValidationResult();
|
|
var format = DetectImportFormat(model.JsonText, formatValidation);
|
|
if (format == ProjectImportFormat.ProjectBackup
|
|
&& TryReadProjectBackup(model.JsonText, out var backup, out var backupValidation)
|
|
&& backup is not null)
|
|
{
|
|
return await ImportProjectBackupAsync(model, backup, backupValidation, userId.Value);
|
|
}
|
|
|
|
if (format == ProjectImportFormat.Unsupported)
|
|
{
|
|
model.Preview = new ImportPreview { Validation = formatValidation };
|
|
return new ImportResult
|
|
{
|
|
Succeeded = false,
|
|
Message = formatValidation.Errors.FirstOrDefault() ?? "The uploaded JSON format is not supported."
|
|
};
|
|
}
|
|
|
|
var preview = await BuildPreviewAsync(model.JsonText);
|
|
model.Preview = preview;
|
|
|
|
if (!preview.Validation.IsValid || preview.Package is null)
|
|
{
|
|
return new ImportResult
|
|
{
|
|
Succeeded = false,
|
|
Message = "The JSON needs to pass validation before it can be imported."
|
|
};
|
|
}
|
|
|
|
if (!model.ConfirmImport)
|
|
{
|
|
return new ImportResult
|
|
{
|
|
Succeeded = false,
|
|
Message = "Confirm the import summary before creating records."
|
|
};
|
|
}
|
|
|
|
if (preview.Validation.Warnings.Any() && !model.AcknowledgeWarnings)
|
|
{
|
|
return new ImportResult
|
|
{
|
|
Succeeded = false,
|
|
Message = "Review and acknowledge the validation warnings before importing."
|
|
};
|
|
}
|
|
|
|
if (preview.HasDuplicateProjectTitle && !model.AllowDuplicateProjectTitle)
|
|
{
|
|
return new ImportResult
|
|
{
|
|
Succeeded = false,
|
|
Message = "A project with this title already exists. Confirm that you want to import another project with the same title."
|
|
};
|
|
}
|
|
|
|
if (model.DryRun)
|
|
{
|
|
logger.LogInformation("JSON import dry run completed for project {ProjectTitle} with {WarningCount} warnings and {UnresolvedReferenceCount} unresolved references.",
|
|
preview.Package.Project.Title,
|
|
preview.WarningCount,
|
|
preview.UnresolvedReferenceCount);
|
|
return BuildDryRunResult(preview);
|
|
}
|
|
|
|
try
|
|
{
|
|
var timer = Stopwatch.StartNew();
|
|
var result = await imports.ImportAsync(preview.Package, preview.HasDuplicateProjectTitle ? preview.DuplicateImportProjectTitle : null, userId.Value);
|
|
timer.Stop();
|
|
logger.LogInformation("JSON import committed in {ElapsedMilliseconds} ms for project {ProjectName}. Created {SceneCount} scenes and skipped/unresolved count {SkippedCount}/{UnresolvedCount}.",
|
|
timer.ElapsedMilliseconds,
|
|
result.ProjectName,
|
|
result.ScenesCreated,
|
|
result.SkippedItemCount,
|
|
result.UnresolvedReferenceCount);
|
|
return result with
|
|
{
|
|
ErrorCount = preview.ErrorCount,
|
|
WarningCount = preview.WarningCount,
|
|
AutoCorrectionCount = preview.AutoCorrectionCount,
|
|
SkippedItemCount = preview.SkippedItemCount,
|
|
UnresolvedReferenceCount = preview.UnresolvedReferenceCount,
|
|
AutoCorrections = preview.AutoCorrections.Concat(preview.Validation.AutoCorrections).Distinct(StringComparer.OrdinalIgnoreCase).ToList()
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "JSON import failed for project title {ProjectTitle}", preview.Package.Project.Title);
|
|
return new ImportResult
|
|
{
|
|
Succeeded = false,
|
|
Message = "The import could not be completed, so no data was saved.",
|
|
TechnicalDetail = ex.Message
|
|
};
|
|
}
|
|
}
|
|
|
|
public async Task<string> BuildValidationReportAsync(ImportIndexViewModel model)
|
|
{
|
|
model.JsonText = await ReadJsonTextAsync(model);
|
|
var preview = await BuildPreviewAsync(model.JsonText);
|
|
var builder = new StringBuilder();
|
|
builder.AppendLine("PlotDirector JSON Import Validation Report");
|
|
builder.AppendLine($"Generated: {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC");
|
|
builder.AppendLine();
|
|
builder.AppendLine($"Project: {preview.Package?.Project.Title ?? "(unreadable)"}");
|
|
builder.AppendLine($"Source: {preview.Package?.Source ?? "(not supplied)"}");
|
|
builder.AppendLine($"Created by: {preview.Package?.CreatedBy ?? "(not supplied)"}");
|
|
builder.AppendLine($"Created date: {preview.Package?.CreatedDate ?? "(not supplied)"}");
|
|
builder.AppendLine($"Description: {preview.Package?.Description ?? "(not supplied)"}");
|
|
builder.AppendLine();
|
|
builder.AppendLine("Health");
|
|
builder.AppendLine($"Errors: {preview.ErrorCount}");
|
|
builder.AppendLine($"Warnings: {preview.WarningCount}");
|
|
builder.AppendLine($"Auto-corrections: {preview.AutoCorrectionCount}");
|
|
builder.AppendLine($"Skipped items: {preview.SkippedItemCount}");
|
|
builder.AppendLine($"Unresolved references: {preview.UnresolvedReferenceCount}");
|
|
builder.AppendLine();
|
|
builder.AppendLine($"Characters: {preview.CharacterCount}");
|
|
builder.AppendLine($"Locations: {preview.LocationCount}");
|
|
builder.AppendLine($"Plot lines: {preview.PlotLineCount}");
|
|
builder.AppendLine($"Plot threads: {preview.PlotThreadCount}");
|
|
builder.AppendLine($"Thread events: {preview.ThreadEventCount}");
|
|
builder.AppendLine($"Story assets: {preview.StoryAssetCount}");
|
|
builder.AppendLine($"Asset states: {preview.AssetStateCount}");
|
|
builder.AppendLine($"Asset events: {preview.AssetEventCount}");
|
|
builder.AppendLine($"Asset custody links: {preview.AssetCustodyCount}");
|
|
builder.AppendLine($"Scene dependencies: {preview.SceneDependencyCount}");
|
|
builder.AppendLine($"Books: {preview.BookCount}");
|
|
builder.AppendLine($"Chapters: {preview.ChapterCount}");
|
|
builder.AppendLine($"Scenes: {preview.SceneCount}");
|
|
builder.AppendLine($"Scene character appearances: {preview.SceneCharacterAppearanceCount}");
|
|
builder.AppendLine();
|
|
AppendReportSection(builder, "Errors", preview.Validation.Errors);
|
|
AppendReportSection(builder, "Warnings", preview.Validation.Warnings);
|
|
AppendReportSection(builder, "Auto-corrections", preview.AutoCorrections.Concat(preview.Validation.AutoCorrections).Distinct(StringComparer.OrdinalIgnoreCase).ToList());
|
|
AppendReportSection(builder, "Skipped items", preview.SkippedItems.Concat(preview.Validation.SkippedItems).Distinct(StringComparer.OrdinalIgnoreCase).ToList());
|
|
AppendReportSection(builder, "Unmatched POV names", preview.UnmatchedPovNames);
|
|
AppendReportSection(builder, "Unmatched location names", preview.UnmatchedLocationNames);
|
|
AppendReportSection(builder, "Unmatched scene character names", preview.UnmatchedSceneCharacterNames);
|
|
AppendReportSection(builder, "Unresolved thread event references", preview.UnresolvedThreadEventReferences);
|
|
AppendReportSection(builder, "Unresolved asset references", preview.UnresolvedAssetReferences);
|
|
AppendReportSection(builder, "Unmatched asset owners", preview.UnmatchedAssetOwnerNames);
|
|
AppendReportSection(builder, "Unmatched asset locations", preview.UnmatchedAssetLocationNames);
|
|
AppendReportSection(builder, "Unresolved scene dependency references", preview.UnresolvedSceneDependencyReferences);
|
|
AppendReportSection(builder, "Alias warnings", preview.AliasWarnings);
|
|
return builder.ToString();
|
|
}
|
|
|
|
private async Task<ImportPreview> BuildPreviewAsync(string jsonText)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(jsonText))
|
|
{
|
|
var emptyValidation = new ImportValidationResult();
|
|
emptyValidation.Errors.Add("Paste JSON or upload a JSON file before previewing.");
|
|
return new ImportPreview { Validation = emptyValidation };
|
|
}
|
|
|
|
var formatValidation = new ImportValidationResult();
|
|
var format = DetectImportFormat(jsonText, formatValidation);
|
|
if (format == ProjectImportFormat.ProjectBackup && TryReadProjectBackup(jsonText, out var backup, out var backupValidation))
|
|
{
|
|
if (backup is null)
|
|
{
|
|
return new ImportPreview { Validation = backupValidation };
|
|
}
|
|
|
|
return await BuildProjectBackupPreviewAsync(backup, backupValidation);
|
|
}
|
|
|
|
if (format == ProjectImportFormat.Unsupported)
|
|
{
|
|
return new ImportPreview { Validation = formatValidation };
|
|
}
|
|
|
|
PlotLineImportPackage? package;
|
|
try
|
|
{
|
|
package = JsonSerializer.Deserialize<PlotLineImportPackage>(jsonText, JsonOptions);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
var invalidJson = new ImportValidationResult();
|
|
invalidJson.Errors.Add($"The JSON could not be read: {ex.Message}");
|
|
return new ImportPreview { Validation = invalidJson };
|
|
}
|
|
|
|
if (package is null)
|
|
{
|
|
var invalidPackage = new ImportValidationResult();
|
|
invalidPackage.Errors.Add("The JSON import package is empty.");
|
|
return new ImportPreview { Validation = invalidPackage };
|
|
}
|
|
|
|
var validation = Validate(package);
|
|
AddMetadataWarnings(package, validation);
|
|
var aliasWarnings = FindAliasWarnings(package);
|
|
foreach (var warning in aliasWarnings)
|
|
{
|
|
validation.Warnings.Add(warning);
|
|
}
|
|
|
|
var duplicate = !string.IsNullOrWhiteSpace(package.Project.Title)
|
|
&& currentUser.UserId.HasValue
|
|
&& await imports.ProjectTitleExistsAsync(package.Project.Title.Trim(), currentUser.UserId.Value);
|
|
|
|
var duplicateTitle = duplicate
|
|
? $"{package.Project.Title.Trim()} (Imported {DateTime.Now:yyyy-MM-dd HH:mm})"
|
|
: null;
|
|
|
|
if (duplicate)
|
|
{
|
|
validation.Warnings.Add($"A project with the same title already exists. If imported anyway, the new project will be named \"{duplicateTitle}\".");
|
|
}
|
|
|
|
return new ImportPreview
|
|
{
|
|
Package = package,
|
|
Validation = validation,
|
|
HasDuplicateProjectTitle = duplicate,
|
|
DuplicateImportProjectTitle = duplicateTitle,
|
|
BookPreviews = BuildBookPreviews(package),
|
|
UnmatchedPovNames = FindUnmatchedPovNames(package),
|
|
UnmatchedLocationNames = FindUnmatchedLocationNames(package),
|
|
UnmatchedSceneCharacterNames = FindUnmatchedSceneCharacterNames(package),
|
|
UnresolvedThreadEventReferences = FindUnresolvedThreadEventReferences(package),
|
|
UnresolvedAssetReferences = FindUnresolvedAssetReferences(package),
|
|
UnmatchedAssetOwnerNames = FindUnmatchedAssetOwnerNames(package),
|
|
UnmatchedAssetLocationNames = FindUnmatchedAssetLocationNames(package),
|
|
UnresolvedSceneDependencyReferences = FindUnresolvedSceneDependencyReferences(package),
|
|
AliasWarnings = aliasWarnings,
|
|
AutoCorrections = FindAutoCorrections(package),
|
|
SkippedItems = FindSkippedItems(package)
|
|
};
|
|
}
|
|
|
|
private async Task<ImportPreview> BuildProjectBackupPreviewAsync(ProjectBackupExport backup, ImportValidationResult validation)
|
|
{
|
|
var package = BuildPreviewPackageFromBackup(backup);
|
|
var projectTitle = package.Project.Title;
|
|
var duplicate = !string.IsNullOrWhiteSpace(projectTitle)
|
|
&& currentUser.UserId.HasValue
|
|
&& await imports.ProjectTitleExistsAsync(projectTitle.Trim(), currentUser.UserId.Value);
|
|
var duplicateTitle = duplicate
|
|
? $"{projectTitle.Trim()} (Imported {DateTime.Now:yyyy-MM-dd HH:mm})"
|
|
: null;
|
|
|
|
validation.Warnings.Add("This is a PlotDirector project backup. It will be imported as a new project, not restored over an existing project.");
|
|
if (duplicate)
|
|
{
|
|
validation.Warnings.Add($"A project with the same title already exists. If imported anyway, the new project will be named \"{duplicateTitle}\".");
|
|
}
|
|
|
|
return new ImportPreview
|
|
{
|
|
Package = package,
|
|
Validation = validation,
|
|
HasDuplicateProjectTitle = duplicate,
|
|
DuplicateImportProjectTitle = duplicateTitle,
|
|
BookPreviews = BuildBookPreviews(package)
|
|
};
|
|
}
|
|
|
|
private async Task<ImportResult> ImportProjectBackupAsync(ImportIndexViewModel model, ProjectBackupExport backup, ImportValidationResult validation, int userId)
|
|
{
|
|
var preview = await BuildProjectBackupPreviewAsync(backup, validation);
|
|
model.Preview = preview;
|
|
|
|
if (!preview.Validation.IsValid)
|
|
{
|
|
return new ImportResult
|
|
{
|
|
Succeeded = false,
|
|
Message = "The project backup needs to pass validation before it can be imported."
|
|
};
|
|
}
|
|
|
|
if (!model.ConfirmImport)
|
|
{
|
|
return new ImportResult
|
|
{
|
|
Succeeded = false,
|
|
Message = "Confirm the import summary before creating records."
|
|
};
|
|
}
|
|
|
|
if (preview.Validation.Warnings.Any() && !model.AcknowledgeWarnings)
|
|
{
|
|
return new ImportResult
|
|
{
|
|
Succeeded = false,
|
|
Message = "Review and acknowledge the validation warnings before importing."
|
|
};
|
|
}
|
|
|
|
if (preview.HasDuplicateProjectTitle && !model.AllowDuplicateProjectTitle)
|
|
{
|
|
return new ImportResult
|
|
{
|
|
Succeeded = false,
|
|
Message = "A project with this title already exists. Confirm that you want to import another project with the adjusted imported title."
|
|
};
|
|
}
|
|
|
|
if (model.DryRun)
|
|
{
|
|
return BuildBackupDryRunResult(preview);
|
|
}
|
|
|
|
try
|
|
{
|
|
var projectName = preview.HasDuplicateProjectTitle
|
|
? preview.DuplicateImportProjectTitle!
|
|
: preview.Package!.Project.Title;
|
|
var projectId = await imports.CreateProjectShellAsync(projectName, backup.Project?.Description, userId);
|
|
var maxIds = await imports.GetIdentityMaxValuesAsync(ProjectBackupIdentityTables);
|
|
var relationshipLookups = await imports.GetRelationshipImportLookupsAsync();
|
|
var remapWarnings = new List<string>();
|
|
RemapProjectBackupForImport(backup, projectId, projectName, maxIds, relationshipLookups, remapWarnings);
|
|
foreach (var warning in remapWarnings)
|
|
{
|
|
logger.LogWarning("Project backup relationship import warning for {ProjectName}: {Warning}", projectName, warning);
|
|
}
|
|
|
|
var restoreJson = JsonSerializer.Serialize(backup, RestoreJsonOptions);
|
|
await imports.ApplyProjectBackupJsonAsync(projectId, restoreJson);
|
|
var relationshipDiagnostics = await imports.GetRelationshipImportDiagnosticsAsync(projectId);
|
|
logger.LogInformation(
|
|
"Project backup relationship import diagnostics for project {ProjectID}: characters {CharacterCount}, relationships {RelationshipCount}, events {RelationshipEventCount}, map-compatible relationships {MapRelationshipCount}, missing source {MissingSourceCharacterCount}, missing target {MissingTargetCharacterCount}, missing type {MissingRelationshipTypeCount}, missing initial state {MissingInitialStateCount}, missing event relationship {MissingEventRelationshipCount}, missing event scene {MissingEventSceneCount}, missing event state {MissingEventStateCount}, archived relationships {ArchivedRelationshipCount}, archived endpoints {ArchivedCharacterEndpointCount}.",
|
|
projectId,
|
|
relationshipDiagnostics.CharacterCount,
|
|
relationshipDiagnostics.RelationshipCount,
|
|
relationshipDiagnostics.RelationshipEventCount,
|
|
relationshipDiagnostics.MapRelationshipCount,
|
|
relationshipDiagnostics.MissingSourceCharacterCount,
|
|
relationshipDiagnostics.MissingTargetCharacterCount,
|
|
relationshipDiagnostics.MissingRelationshipTypeCount,
|
|
relationshipDiagnostics.MissingInitialStateCount,
|
|
relationshipDiagnostics.MissingEventRelationshipCount,
|
|
relationshipDiagnostics.MissingEventSceneCount,
|
|
relationshipDiagnostics.MissingEventStateCount,
|
|
relationshipDiagnostics.ArchivedRelationshipCount,
|
|
relationshipDiagnostics.ArchivedCharacterEndpointCount);
|
|
|
|
if (relationshipDiagnostics.RelationshipCount > 0
|
|
&& relationshipDiagnostics.MapRelationshipCount + relationshipDiagnostics.ArchivedRelationshipCount < relationshipDiagnostics.RelationshipCount)
|
|
{
|
|
logger.LogWarning("Imported project {ProjectID} has relationship records that are not compatible with Relationship Map joins. Check relationship import diagnostics for missing endpoint/type/state counts.", projectId);
|
|
}
|
|
|
|
return new ImportResult
|
|
{
|
|
Succeeded = true,
|
|
ProjectID = projectId,
|
|
ProjectName = projectName,
|
|
BooksCreated = backup.Data.Books.Count,
|
|
ChaptersCreated = backup.Data.Chapters.Count,
|
|
ScenesCreated = backup.Data.Scenes.Count,
|
|
CharactersCreated = backup.Data.Characters.Count,
|
|
LocationsCreated = backup.Data.Locations.Count,
|
|
SceneCharacterAppearancesCreated = backup.Data.SceneCharacters.Count,
|
|
PlotLinesCreated = backup.Data.PlotLines.Count,
|
|
PlotThreadsCreated = backup.Data.PlotThreads.Count,
|
|
ThreadEventsCreated = backup.Data.ThreadEvents.Count,
|
|
StoryAssetsCreated = backup.Data.StoryAssets.Count,
|
|
AssetStatesCreated = backup.Data.AssetStates.Count(x => x.ProjectID == projectId),
|
|
AssetEventsCreated = backup.Data.AssetEvents.Count,
|
|
AssetCustodyEventsCreated = backup.Data.AssetCustodyEvents.Count,
|
|
SceneDependenciesCreated = backup.Data.SceneDependencies.Count,
|
|
WarningCount = preview.WarningCount,
|
|
Message = "Project backup imported as a new project."
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Project backup import failed for project title {ProjectTitle}.", preview.Package?.Project.Title);
|
|
return new ImportResult
|
|
{
|
|
Succeeded = false,
|
|
Message = "The project backup could not be imported, so no project data was saved.",
|
|
TechnicalDetail = ex.Message
|
|
};
|
|
}
|
|
}
|
|
|
|
private static ImportResult BuildBackupDryRunResult(ImportPreview preview)
|
|
=> new()
|
|
{
|
|
Succeeded = true,
|
|
IsDryRun = true,
|
|
ProjectName = preview.Package?.Project.Title ?? "Dry run",
|
|
BooksCreated = preview.BookCount,
|
|
ChaptersCreated = preview.ChapterCount,
|
|
ScenesCreated = preview.SceneCount,
|
|
CharactersCreated = preview.CharacterCount,
|
|
LocationsCreated = preview.LocationCount,
|
|
SceneCharacterAppearancesCreated = preview.SceneCharacterAppearanceCount,
|
|
PlotLinesCreated = preview.PlotLineCount,
|
|
PlotThreadsCreated = preview.PlotThreadCount,
|
|
ThreadEventsCreated = preview.ThreadEventCount,
|
|
StoryAssetsCreated = preview.StoryAssetCount,
|
|
AssetStatesCreated = preview.AssetStateCount,
|
|
AssetEventsCreated = preview.AssetEventCount,
|
|
AssetCustodyEventsCreated = preview.AssetCustodyCount,
|
|
SceneDependenciesCreated = preview.SceneDependencyCount,
|
|
WarningCount = preview.WarningCount,
|
|
Message = "Dry run complete. No data was saved."
|
|
};
|
|
|
|
private static bool TryReadProjectBackup(string jsonText, out ProjectBackupExport? backup, out ImportValidationResult validation)
|
|
{
|
|
backup = null;
|
|
validation = new ImportValidationResult();
|
|
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(jsonText, new JsonDocumentOptions
|
|
{
|
|
AllowTrailingCommas = true,
|
|
CommentHandling = JsonCommentHandling.Skip
|
|
});
|
|
|
|
if (!TryGetString(document.RootElement, "format", out var format)
|
|
&& !TryGetString(document.RootElement, "Format", out format))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!string.Equals(format, "PlotDirector.ProjectBackup", StringComparison.Ordinal))
|
|
{
|
|
validation.Errors.Add("The project backup format is not recognised.");
|
|
return true;
|
|
}
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
validation.Errors.Add($"The JSON could not be read: {ex.Message}");
|
|
return true;
|
|
}
|
|
|
|
try
|
|
{
|
|
backup = JsonSerializer.Deserialize<ProjectBackupExport>(jsonText, JsonOptions);
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
validation.Errors.Add($"The project backup could not be read: {ex.Message}");
|
|
return true;
|
|
}
|
|
|
|
if (backup is null)
|
|
{
|
|
validation.Errors.Add("The project backup is empty.");
|
|
return true;
|
|
}
|
|
|
|
if (!string.Equals(backup.Format, "PlotDirector.ProjectBackup", StringComparison.Ordinal))
|
|
{
|
|
validation.Errors.Add("The project backup format is not recognised.");
|
|
}
|
|
|
|
if (!SupportedProjectBackupVersions.Contains(backup.FormatVersion, StringComparer.Ordinal))
|
|
{
|
|
validation.Errors.Add($"Project backup format version \"{backup.FormatVersion}\" is not supported.");
|
|
}
|
|
|
|
if (backup.Project is null || string.IsNullOrWhiteSpace(backup.Project.ProjectName))
|
|
{
|
|
validation.Errors.Add("The project backup does not contain a project name.");
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static ProjectImportFormat DetectImportFormat(string jsonText, ImportValidationResult validation)
|
|
{
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(jsonText, new JsonDocumentOptions
|
|
{
|
|
AllowTrailingCommas = true,
|
|
CommentHandling = JsonCommentHandling.Skip
|
|
});
|
|
var root = document.RootElement;
|
|
|
|
if (TryGetString(root, "format", out var format)
|
|
|| TryGetString(root, "Format", out format))
|
|
{
|
|
if (string.Equals(format, "PlotDirector.ProjectBackup", StringComparison.Ordinal))
|
|
{
|
|
return ProjectImportFormat.ProjectBackup;
|
|
}
|
|
|
|
validation.Errors.Add($"The project import format \"{format}\" is not supported.");
|
|
return ProjectImportFormat.Unsupported;
|
|
}
|
|
|
|
if ((TryGetString(root, "packageVersion", out _) || TryGetString(root, "PackageVersion", out _))
|
|
&& (root.TryGetProperty("books", out _)
|
|
|| root.TryGetProperty("Books", out _)
|
|
|| root.TryGetProperty("characters", out _)
|
|
|| root.TryGetProperty("Characters", out _)
|
|
|| root.TryGetProperty("locations", out _)
|
|
|| root.TryGetProperty("Locations", out _)))
|
|
{
|
|
return ProjectImportFormat.LegacyPackage;
|
|
}
|
|
|
|
validation.Errors.Add("The uploaded JSON is not a recognised PlotDirector import package or project backup.");
|
|
return ProjectImportFormat.Unsupported;
|
|
}
|
|
catch (JsonException ex)
|
|
{
|
|
validation.Errors.Add($"The JSON could not be read: {ex.Message}");
|
|
return ProjectImportFormat.Unsupported;
|
|
}
|
|
}
|
|
|
|
private static bool TryGetString(JsonElement element, string propertyName, out string? value)
|
|
{
|
|
value = null;
|
|
if (!element.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.String)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
value = property.GetString();
|
|
return true;
|
|
}
|
|
|
|
private static PlotLineImportPackage BuildPreviewPackageFromBackup(ProjectBackupExport backup)
|
|
{
|
|
var package = new PlotLineImportPackage
|
|
{
|
|
PackageVersion = backup.FormatVersion,
|
|
Source = backup.Format,
|
|
CreatedBy = backup.SourceApplication,
|
|
CreatedDate = backup.ExportedAtUtc.ToString("yyyy-MM-dd HH:mm 'UTC'"),
|
|
Description = "Imported from a PlotDirector project backup.",
|
|
Project = new ImportProjectDto
|
|
{
|
|
Title = backup.Project?.ProjectName ?? string.Empty,
|
|
Description = backup.Project?.Description
|
|
},
|
|
Characters = backup.Data.Characters
|
|
.Select(character => new ImportCharacterDto
|
|
{
|
|
Name = character.CharacterName,
|
|
DisplayName = character.CharacterName,
|
|
ShortName = character.ShortName,
|
|
Importance = character.CharacterImportance,
|
|
Description = character.DefaultDescription
|
|
})
|
|
.ToList(),
|
|
Locations = backup.Data.Locations
|
|
.Select(location => new ImportLocationDto
|
|
{
|
|
Name = location.LocationName,
|
|
DisplayName = location.LocationName,
|
|
Type = location.LocationTypeName,
|
|
ParentLocationName = location.ParentLocationName,
|
|
Description = location.Description
|
|
})
|
|
.ToList(),
|
|
PlotLines = backup.Data.PlotLines
|
|
.Select(plotLine => new ImportPlotLineDto
|
|
{
|
|
Name = plotLine.PlotLineName,
|
|
DisplayName = plotLine.PlotLineName,
|
|
Type = plotLine.PlotLineTypeName,
|
|
Description = plotLine.Description,
|
|
Threads = backup.Data.PlotThreads
|
|
.Where(thread => thread.PlotLineID == plotLine.PlotLineID)
|
|
.Select(thread => new ImportPlotThreadDto
|
|
{
|
|
Name = thread.ThreadTitle,
|
|
DisplayName = thread.ThreadTitle,
|
|
Type = thread.ThreadTypeName,
|
|
Status = thread.ThreadStatusName,
|
|
Importance = thread.Importance,
|
|
Description = thread.Summary,
|
|
Events = backup.Data.ThreadEvents
|
|
.Where(threadEvent => threadEvent.PlotThreadID == thread.PlotThreadID)
|
|
.Select(threadEvent => new ImportThreadEventDto
|
|
{
|
|
Title = threadEvent.EventTitle,
|
|
EventType = threadEvent.EventTypeName,
|
|
Summary = threadEvent.EventDescription
|
|
})
|
|
.ToList()
|
|
})
|
|
.ToList()
|
|
})
|
|
.ToList(),
|
|
StoryAssets = backup.Data.StoryAssets
|
|
.Select(asset => new ImportStoryAssetDto
|
|
{
|
|
Name = asset.AssetName,
|
|
DisplayName = asset.AssetName,
|
|
Type = asset.KindName,
|
|
Importance = asset.Importance,
|
|
Description = asset.Description,
|
|
InitialLocationName = asset.CurrentLocationName,
|
|
States = backup.Data.AssetEvents
|
|
.Where(assetEvent => assetEvent.StoryAssetID == asset.StoryAssetID)
|
|
.Select(assetEvent => new ImportAssetStateDto { StateName = assetEvent.ToStateName, Description = assetEvent.EventDescription })
|
|
.ToList(),
|
|
Events = backup.Data.AssetEvents
|
|
.Where(assetEvent => assetEvent.StoryAssetID == asset.StoryAssetID)
|
|
.Select(assetEvent => new ImportAssetEventDto { Title = assetEvent.EventTitle, EventType = assetEvent.AssetEventTypeName, Summary = assetEvent.EventDescription })
|
|
.ToList()
|
|
})
|
|
.ToList(),
|
|
Books = backup.Data.Books
|
|
.OrderBy(book => book.SortOrder)
|
|
.ThenBy(book => book.BookNumber)
|
|
.Select(book => new ImportBookDto
|
|
{
|
|
Title = book.BookTitle,
|
|
SeriesOrder = book.BookNumber,
|
|
Description = book.Description,
|
|
Chapters = backup.Data.Chapters
|
|
.Where(chapter => chapter.BookID == book.BookID)
|
|
.OrderBy(chapter => chapter.SortOrder)
|
|
.Select(chapter => new ImportChapterDto
|
|
{
|
|
ChapterNumber = chapter.ChapterNumber,
|
|
Title = chapter.ChapterTitle,
|
|
Order = chapter.SortOrder,
|
|
Summary = chapter.Summary,
|
|
Scenes = backup.Data.Scenes
|
|
.Where(scene => scene.ChapterID == chapter.ChapterID)
|
|
.OrderBy(scene => scene.SortOrder)
|
|
.Select(scene => new ImportSceneDto
|
|
{
|
|
SceneNumber = scene.SceneNumber,
|
|
Title = scene.SceneTitle,
|
|
Order = scene.SortOrder,
|
|
Summary = scene.Summary,
|
|
PovCharacterName = scene.POVCharacterID.HasValue
|
|
? backup.Data.Characters.FirstOrDefault(character => character.CharacterID == scene.POVCharacterID.Value)?.CharacterName
|
|
: null,
|
|
LocationName = scene.PrimaryLocationName,
|
|
DateTimeText = scene.RelativeTimeText,
|
|
PurposeText = scene.ScenePurposeNotes,
|
|
OutcomeText = scene.SceneOutcomeNotes,
|
|
SceneCharacters = backup.Data.SceneCharacters
|
|
.Where(appearance => appearance.SceneID == scene.SceneID)
|
|
.Select(appearance => new ImportSceneCharacterDto
|
|
{
|
|
CharacterName = appearance.CharacterName,
|
|
RoleInScene = appearance.RoleInSceneTypeName,
|
|
Notes = appearance.AppearanceNotes
|
|
})
|
|
.ToList()
|
|
})
|
|
.ToList()
|
|
})
|
|
.ToList()
|
|
})
|
|
.ToList()
|
|
};
|
|
|
|
return package;
|
|
}
|
|
|
|
private static readonly string[] ProjectBackupIdentityTables =
|
|
[
|
|
"ProjectTimelineSettings", "TimelineViewPresets", "SceneMetricTypes", "Books", "Chapters", "Scenes",
|
|
"SceneOutcomes", "PlotLines", "PlotThreads", "ThreadEvents", "AssetStates", "StoryAssets", "AssetEvents",
|
|
"AssetDependencies", "AssetCustodyEvents", "AssetCustodyEventCharacters", "Characters", "SceneCharacters",
|
|
"CharacterAttributeTypes", "CharacterAttributeEvents", "CharacterKnowledge", "CharacterRelationships",
|
|
"RelationshipEvents", "Locations", "LocationRelationships", "FloorPlans", "FloorPlanFloors",
|
|
"FloorPlanBlocks", "FloorPlanTransitions", "SceneAssetLocations", "SceneFloorPlanOccupancy",
|
|
"SceneDependencies", "ValidationRuns", "ContinuityWarnings", "SceneNotes", "SceneWorkflow", "SceneChecklistItems",
|
|
"SceneAttachments", "ChapterGoals", "Scenarios", "ScenarioSceneOrder", "ScenarioValidationRuns",
|
|
"ScenarioWarnings"
|
|
];
|
|
|
|
private static readonly string[] SupportedProjectBackupVersions = ["1.0", "1.1"];
|
|
|
|
private static void RemapProjectBackupForImport(
|
|
ProjectBackupExport backup,
|
|
int projectId,
|
|
string projectName,
|
|
IReadOnlyDictionary<string, int> maxIds,
|
|
ProjectBackupRelationshipImportLookups relationshipLookups,
|
|
ICollection<string> warnings)
|
|
{
|
|
if (backup.Project is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var maps = new Dictionary<string, Dictionary<int, int>>(StringComparer.OrdinalIgnoreCase);
|
|
var nextIds = ProjectBackupIdentityTables.ToDictionary(table => table, table => maxIds.TryGetValue(table, out var maxId) ? maxId : 0, StringComparer.OrdinalIgnoreCase);
|
|
int Next(string table) => nextIds[table] = nextIds[table] + 1;
|
|
Dictionary<int, int> Map(string table) => maps.TryGetValue(table, out var map) ? map : maps[table] = new Dictionary<int, int>();
|
|
int NewId(string table, int oldId)
|
|
{
|
|
var map = Map(table);
|
|
if (!map.TryGetValue(oldId, out var newId))
|
|
{
|
|
newId = Next(table);
|
|
map[oldId] = newId;
|
|
}
|
|
|
|
return newId;
|
|
}
|
|
int ExistingOrNew(string table, int oldId) => maps.TryGetValue(table, out var map) && map.TryGetValue(oldId, out var newId) ? newId : oldId;
|
|
int? ExistingOrNewNullable(string table, int? oldId) => oldId.HasValue ? ExistingOrNew(table, oldId.Value) : null;
|
|
static Dictionary<string, int> LookupByName(IEnumerable<ProjectBackupLookupRow> rows)
|
|
=> rows.Where(x => !string.IsNullOrWhiteSpace(x.LookupName))
|
|
.GroupBy(x => x.LookupName.Trim(), StringComparer.OrdinalIgnoreCase)
|
|
.ToDictionary(x => x.Key, x => x.First().LookupID, StringComparer.OrdinalIgnoreCase);
|
|
|
|
var revisionStatusIds = LookupByName(relationshipLookups.RevisionStatuses);
|
|
var timeModeIds = LookupByName(relationshipLookups.TimeModes);
|
|
var durationUnitIds = LookupByName(relationshipLookups.DurationUnits);
|
|
var timeConfidenceIds = LookupByName(relationshipLookups.TimeConfidences);
|
|
var locationTypeIds = LookupByName(relationshipLookups.LocationTypes);
|
|
var locationRelationshipTypeIds = LookupByName(relationshipLookups.LocationRelationshipTypes);
|
|
var plotLineTypeIds = LookupByName(relationshipLookups.PlotLineTypes);
|
|
var plotImportanceIds = LookupByName(relationshipLookups.PlotImportance);
|
|
var threadTypeIds = LookupByName(relationshipLookups.ThreadTypes);
|
|
var threadStatusIds = LookupByName(relationshipLookups.ThreadStatuses);
|
|
var threadEventTypeIds = LookupByName(relationshipLookups.ThreadEventTypes);
|
|
var plotEventTypeIds = LookupByName(relationshipLookups.PlotEventTypes);
|
|
var assetKindIds = LookupByName(relationshipLookups.AssetKinds);
|
|
var assetStateIds = LookupByName(relationshipLookups.AssetStates);
|
|
var assetEventTypeIds = LookupByName(relationshipLookups.AssetEventTypes);
|
|
var assetDependencyTypeIds = LookupByName(relationshipLookups.AssetDependencyTypes);
|
|
var assetCustodyEventTypeIds = LookupByName(relationshipLookups.AssetCustodyEventTypes);
|
|
var custodyRoleIds = LookupByName(relationshipLookups.CustodyRoles);
|
|
var characterRoleTypeIds = LookupByName(relationshipLookups.CharacterRoleTypes);
|
|
var presenceTypeIds = LookupByName(relationshipLookups.PresenceTypes);
|
|
var knowledgeStateIds = LookupByName(relationshipLookups.KnowledgeStates);
|
|
var sceneDependencyTypeIds = LookupByName(relationshipLookups.SceneDependencyTypes);
|
|
var sceneNoteTypeIds = LookupByName(relationshipLookups.SceneNoteTypes);
|
|
var sceneAttachmentTypeIds = LookupByName(relationshipLookups.SceneAttachmentTypes);
|
|
var scenePurposeTypeIds = LookupByName(relationshipLookups.ScenePurposeTypes);
|
|
var sceneMetricTypeIds = LookupByName(relationshipLookups.SceneMetricTypes);
|
|
var relationshipTypeIdsByName = relationshipLookups.RelationshipTypes
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.TypeName))
|
|
.GroupBy(x => x.TypeName.Trim(), StringComparer.OrdinalIgnoreCase)
|
|
.ToDictionary(x => x.Key, x => x.First().RelationshipTypeID, StringComparer.OrdinalIgnoreCase);
|
|
var relationshipStateIdsByName = relationshipLookups.RelationshipStates
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.StateName))
|
|
.GroupBy(x => x.StateName.Trim(), StringComparer.OrdinalIgnoreCase)
|
|
.ToDictionary(x => x.Key, x => x.First().RelationshipStateID, StringComparer.OrdinalIgnoreCase);
|
|
int ResolveLookupID(Dictionary<string, int> lookup, int currentId, string? lookupName, string context)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(lookupName) && lookup.TryGetValue(lookupName.Trim(), out var resolvedId))
|
|
{
|
|
return resolvedId;
|
|
}
|
|
|
|
warnings.Add($"{context} has lookup value \"{lookupName}\" which was not found in the target database. The exported ID {currentId} was retained.");
|
|
return currentId;
|
|
}
|
|
int? ResolveNullableLookupID(Dictionary<string, int> lookup, int? currentId, string? lookupName, string context)
|
|
{
|
|
if (!currentId.HasValue)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return ResolveLookupID(lookup, currentId.Value, lookupName, context);
|
|
}
|
|
int? ResolveMappedOrLookupID(string table, Dictionary<string, int> lookup, int? currentId, string? lookupName, string context)
|
|
{
|
|
if (!currentId.HasValue)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (maps.TryGetValue(table, out var map) && map.TryGetValue(currentId.Value, out var mappedId))
|
|
{
|
|
return mappedId;
|
|
}
|
|
|
|
return ResolveLookupID(lookup, currentId.Value, lookupName, context);
|
|
}
|
|
int ResolveRelationshipTypeID(CharacterRelationship relationship)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(relationship.RelationshipTypeName)
|
|
&& relationshipTypeIdsByName.TryGetValue(relationship.RelationshipTypeName.Trim(), out var relationshipTypeId))
|
|
{
|
|
return relationshipTypeId;
|
|
}
|
|
|
|
warnings.Add($"Relationship {relationship.CharacterRelationshipID} between \"{relationship.CharacterAName}\" and \"{relationship.CharacterBName}\" has relationship type \"{relationship.RelationshipTypeName}\" which was not found in the target database. The exported ID {relationship.RelationshipTypeID} was retained.");
|
|
return relationship.RelationshipTypeID;
|
|
}
|
|
int? ResolveRelationshipStateID(int? stateId, string? stateName, string context)
|
|
{
|
|
if (!stateId.HasValue)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(stateName)
|
|
&& relationshipStateIdsByName.TryGetValue(stateName.Trim(), out var relationshipStateId))
|
|
{
|
|
return relationshipStateId;
|
|
}
|
|
|
|
warnings.Add($"{context} has relationship state \"{stateName}\" which was not found in the target database. The exported ID {stateId.Value} was retained.");
|
|
return stateId;
|
|
}
|
|
|
|
backup.Project.ProjectID = projectId;
|
|
backup.Project.ProjectName = projectName;
|
|
backup.Project.IsArchived = false;
|
|
backup.Project.ArchivedDate = null;
|
|
backup.Project.ArchivedReason = null;
|
|
|
|
foreach (var settings in backup.Data.TimelineSettings)
|
|
{
|
|
settings.ProjectTimelineSettingsID = NewId("ProjectTimelineSettings", settings.ProjectTimelineSettingsID);
|
|
settings.ProjectID = projectId;
|
|
}
|
|
|
|
foreach (var metric in backup.Data.SceneMetricTypes.Where(metric => metric.ProjectID.HasValue))
|
|
{
|
|
metric.MetricTypeID = NewId("SceneMetricTypes", metric.MetricTypeID);
|
|
metric.ProjectID = projectId;
|
|
}
|
|
|
|
foreach (var assetState in backup.Data.AssetStates.Where(state => state.ProjectID.HasValue))
|
|
{
|
|
assetState.AssetStateID = NewId("AssetStates", assetState.AssetStateID);
|
|
assetState.ProjectID = projectId;
|
|
assetState.AssetKindID = ResolveNullableLookupID(assetKindIds, assetState.AssetKindID, assetState.KindName, $"Asset state \"{assetState.StateName}\" kind");
|
|
}
|
|
|
|
foreach (var attributeType in backup.Data.CharacterAttributeTypes.Where(type => type.ProjectID.HasValue))
|
|
{
|
|
attributeType.CharacterAttributeTypeID = NewId("CharacterAttributeTypes", attributeType.CharacterAttributeTypeID);
|
|
attributeType.ProjectID = projectId;
|
|
}
|
|
|
|
foreach (var book in backup.Data.Books)
|
|
{
|
|
book.BookID = NewId("Books", book.BookID);
|
|
book.ProjectID = projectId;
|
|
}
|
|
|
|
foreach (var character in backup.Data.Characters)
|
|
{
|
|
character.CharacterID = NewId("Characters", character.CharacterID);
|
|
character.ProjectID = projectId;
|
|
character.AgeReferenceSceneID = ExistingOrNewNullable("Scenes", character.AgeReferenceSceneID);
|
|
}
|
|
|
|
foreach (var location in backup.Data.Locations)
|
|
{
|
|
location.LocationID = NewId("Locations", location.LocationID);
|
|
location.ProjectID = projectId;
|
|
location.LocationTypeID = ResolveNullableLookupID(locationTypeIds, location.LocationTypeID, location.LocationTypeName, $"Location \"{location.LocationName}\" type");
|
|
}
|
|
|
|
foreach (var floorPlan in backup.Data.FloorPlans)
|
|
{
|
|
floorPlan.FloorPlanID = NewId("FloorPlans", floorPlan.FloorPlanID);
|
|
floorPlan.ProjectID = projectId;
|
|
floorPlan.LocationID = ExistingOrNewNullable("Locations", floorPlan.LocationID);
|
|
}
|
|
|
|
foreach (var floor in backup.Data.FloorPlanFloors)
|
|
{
|
|
floor.FloorPlanFloorID = NewId("FloorPlanFloors", floor.FloorPlanFloorID);
|
|
floor.FloorPlanID = ExistingOrNew("FloorPlans", floor.FloorPlanID);
|
|
floor.LocationID = ExistingOrNewNullable("Locations", floor.LocationID);
|
|
}
|
|
|
|
foreach (var block in backup.Data.FloorPlanBlocks)
|
|
{
|
|
block.FloorPlanBlockID = NewId("FloorPlanBlocks", block.FloorPlanBlockID);
|
|
block.FloorPlanFloorID = ExistingOrNew("FloorPlanFloors", block.FloorPlanFloorID);
|
|
block.LocationID = ExistingOrNew("Locations", block.LocationID);
|
|
}
|
|
|
|
foreach (var transition in backup.Data.FloorPlanTransitions)
|
|
{
|
|
transition.FloorPlanTransitionID = NewId("FloorPlanTransitions", transition.FloorPlanTransitionID);
|
|
transition.FloorPlanFloorID = ExistingOrNew("FloorPlanFloors", transition.FloorPlanFloorID);
|
|
transition.FromLocationID = ExistingOrNew("Locations", transition.FromLocationID);
|
|
transition.ToLocationID = ExistingOrNew("Locations", transition.ToLocationID);
|
|
}
|
|
|
|
foreach (var chapter in backup.Data.Chapters)
|
|
{
|
|
chapter.ChapterID = NewId("Chapters", chapter.ChapterID);
|
|
chapter.BookID = ExistingOrNew("Books", chapter.BookID);
|
|
chapter.POVCharacterID = ExistingOrNewNullable("Characters", chapter.POVCharacterID);
|
|
chapter.RevisionStatusID = ResolveLookupID(revisionStatusIds, chapter.RevisionStatusID, chapter.RevisionStatusName, $"Chapter \"{chapter.ChapterTitle}\" revision status");
|
|
}
|
|
|
|
foreach (var scene in backup.Data.Scenes)
|
|
{
|
|
scene.SceneID = NewId("Scenes", scene.SceneID);
|
|
scene.ChapterID = ExistingOrNew("Chapters", scene.ChapterID);
|
|
scene.POVCharacterID = ExistingOrNewNullable("Characters", scene.POVCharacterID);
|
|
scene.PrimaryLocationID = ExistingOrNewNullable("Locations", scene.PrimaryLocationID);
|
|
scene.FloorPlanID = ExistingOrNewNullable("FloorPlans", scene.FloorPlanID);
|
|
scene.InitialFloorPlanFloorID = ExistingOrNewNullable("FloorPlanFloors", scene.InitialFloorPlanFloorID);
|
|
scene.TimeModeID = ResolveLookupID(timeModeIds, scene.TimeModeID, scene.TimeModeName, $"Scene \"{scene.SceneTitle}\" time mode");
|
|
scene.DurationUnitID = ResolveNullableLookupID(durationUnitIds, scene.DurationUnitID, scene.DurationUnitName, $"Scene \"{scene.SceneTitle}\" duration unit");
|
|
scene.TimeConfidenceID = ResolveLookupID(timeConfidenceIds, scene.TimeConfidenceID, scene.TimeConfidenceName, $"Scene \"{scene.SceneTitle}\" time confidence");
|
|
scene.RevisionStatusID = ResolveLookupID(revisionStatusIds, scene.RevisionStatusID, scene.RevisionStatusName, $"Scene \"{scene.SceneTitle}\" revision status");
|
|
}
|
|
|
|
foreach (var location in backup.Data.Locations)
|
|
{
|
|
location.ParentLocationID = ExistingOrNewNullable("Locations", location.ParentLocationID);
|
|
}
|
|
|
|
foreach (var character in backup.Data.Characters)
|
|
{
|
|
character.AgeReferenceSceneID = ExistingOrNewNullable("Scenes", character.AgeReferenceSceneID);
|
|
}
|
|
|
|
foreach (var purpose in backup.Data.ScenePurposes)
|
|
{
|
|
purpose.SceneID = ExistingOrNew("Scenes", purpose.SceneID);
|
|
purpose.ScenePurposeTypeID = ResolveLookupID(scenePurposeTypeIds, purpose.ScenePurposeTypeID, purpose.PurposeName, $"Scene purpose \"{purpose.PurposeName}\"");
|
|
}
|
|
|
|
foreach (var outcome in backup.Data.SceneOutcomes)
|
|
{
|
|
outcome.SceneOutcomeID = NewId("SceneOutcomes", outcome.SceneOutcomeID);
|
|
outcome.SceneID = ExistingOrNew("Scenes", outcome.SceneID);
|
|
}
|
|
|
|
foreach (var metricValue in backup.Data.SceneMetricValues)
|
|
{
|
|
metricValue.SceneID = ExistingOrNew("Scenes", metricValue.SceneID);
|
|
metricValue.MetricTypeID = ResolveMappedOrLookupID("SceneMetricTypes", sceneMetricTypeIds, metricValue.MetricTypeID, metricValue.MetricName, $"Scene metric value \"{metricValue.MetricName}\"") ?? metricValue.MetricTypeID;
|
|
}
|
|
|
|
foreach (var metricSetting in backup.Data.TimelineMetricSettings)
|
|
{
|
|
metricSetting.ProjectID = projectId;
|
|
metricSetting.MetricTypeID = ResolveMappedOrLookupID("SceneMetricTypes", sceneMetricTypeIds, metricSetting.MetricTypeID, metricSetting.MetricName, $"Timeline metric setting \"{metricSetting.MetricName}\"") ?? metricSetting.MetricTypeID;
|
|
}
|
|
|
|
foreach (var preset in backup.Data.TimelineViewPresets)
|
|
{
|
|
preset.TimelineViewPresetID = NewId("TimelineViewPresets", preset.TimelineViewPresetID);
|
|
preset.ProjectID = projectId;
|
|
preset.BookID = ExistingOrNewNullable("Books", preset.BookID);
|
|
}
|
|
|
|
foreach (var plotLine in backup.Data.PlotLines)
|
|
{
|
|
plotLine.PlotLineID = NewId("PlotLines", plotLine.PlotLineID);
|
|
plotLine.ProjectID = projectId;
|
|
plotLine.BookID = ExistingOrNewNullable("Books", plotLine.BookID);
|
|
plotLine.PlotLineTypeID = ResolveLookupID(plotLineTypeIds, plotLine.PlotLineTypeID, plotLine.PlotLineTypeName, $"Plot line \"{plotLine.PlotLineName}\" type");
|
|
plotLine.PlotImportanceID = ResolveLookupID(plotImportanceIds, plotLine.PlotImportanceID, plotLine.PlotImportanceName, $"Plot line \"{plotLine.PlotLineName}\" importance");
|
|
}
|
|
|
|
foreach (var plotLine in backup.Data.PlotLines)
|
|
{
|
|
plotLine.ParentPlotLineID = ExistingOrNewNullable("PlotLines", plotLine.ParentPlotLineID);
|
|
plotLine.EmergesFromPlotLineID = ExistingOrNewNullable("PlotLines", plotLine.EmergesFromPlotLineID);
|
|
}
|
|
|
|
foreach (var thread in backup.Data.PlotThreads)
|
|
{
|
|
thread.PlotThreadID = NewId("PlotThreads", thread.PlotThreadID);
|
|
thread.PlotLineID = ExistingOrNew("PlotLines", thread.PlotLineID);
|
|
thread.ProjectID = projectId;
|
|
thread.ThreadTypeID = ResolveLookupID(threadTypeIds, thread.ThreadTypeID, thread.ThreadTypeName, $"Plot thread \"{thread.ThreadTitle}\" type");
|
|
thread.ThreadStatusID = ResolveLookupID(threadStatusIds, thread.ThreadStatusID, thread.ThreadStatusName, $"Plot thread \"{thread.ThreadTitle}\" status");
|
|
thread.IntroducedSceneID = ExistingOrNewNullable("Scenes", thread.IntroducedSceneID);
|
|
thread.PlannedResolutionSceneID = ExistingOrNewNullable("Scenes", thread.PlannedResolutionSceneID);
|
|
thread.ActualResolutionSceneID = ExistingOrNewNullable("Scenes", thread.ActualResolutionSceneID);
|
|
}
|
|
|
|
foreach (var threadEvent in backup.Data.ThreadEvents)
|
|
{
|
|
threadEvent.ThreadEventID = NewId("ThreadEvents", threadEvent.ThreadEventID);
|
|
threadEvent.PlotThreadID = ExistingOrNew("PlotThreads", threadEvent.PlotThreadID);
|
|
threadEvent.PlotLineID = ExistingOrNew("PlotLines", threadEvent.PlotLineID);
|
|
threadEvent.SceneID = ExistingOrNew("Scenes", threadEvent.SceneID);
|
|
threadEvent.EventTypeID = ResolveLookupID(threadEventTypeIds, threadEvent.EventTypeID, threadEvent.EventTypeName, $"Thread event \"{threadEvent.EventTitle}\" type");
|
|
threadEvent.PlotEventTypeID = ResolveLookupID(plotEventTypeIds, threadEvent.PlotEventTypeID, threadEvent.PlotEventTypeName, $"Thread event \"{threadEvent.EventTitle}\" plot event type");
|
|
threadEvent.TargetPlotLineID = ExistingOrNewNullable("PlotLines", threadEvent.TargetPlotLineID);
|
|
}
|
|
|
|
foreach (var asset in backup.Data.StoryAssets)
|
|
{
|
|
asset.StoryAssetID = NewId("StoryAssets", asset.StoryAssetID);
|
|
asset.ProjectID = projectId;
|
|
asset.AssetKindID = ResolveLookupID(assetKindIds, asset.AssetKindID, asset.KindName, $"Story asset \"{asset.AssetName}\" kind");
|
|
asset.CurrentStateID = ResolveMappedOrLookupID("AssetStates", assetStateIds, asset.CurrentStateID, asset.CurrentStateName, $"Story asset \"{asset.AssetName}\" current state");
|
|
asset.CurrentLocationID = ExistingOrNewNullable("Locations", asset.CurrentLocationID);
|
|
}
|
|
|
|
foreach (var assetEvent in backup.Data.AssetEvents)
|
|
{
|
|
assetEvent.AssetEventID = NewId("AssetEvents", assetEvent.AssetEventID);
|
|
assetEvent.StoryAssetID = ExistingOrNew("StoryAssets", assetEvent.StoryAssetID);
|
|
assetEvent.ProjectID = projectId;
|
|
assetEvent.SceneID = ExistingOrNew("Scenes", assetEvent.SceneID);
|
|
assetEvent.AssetKindID = ResolveLookupID(assetKindIds, assetEvent.AssetKindID, assetEvent.KindName, $"Asset event \"{assetEvent.EventTitle}\" kind");
|
|
assetEvent.AssetEventTypeID = ResolveLookupID(assetEventTypeIds, assetEvent.AssetEventTypeID, assetEvent.AssetEventTypeName, $"Asset event \"{assetEvent.EventTitle}\" type");
|
|
assetEvent.FromStateID = ResolveMappedOrLookupID("AssetStates", assetStateIds, assetEvent.FromStateID, assetEvent.FromStateName, $"Asset event \"{assetEvent.EventTitle}\" from state");
|
|
assetEvent.ToStateID = ResolveMappedOrLookupID("AssetStates", assetStateIds, assetEvent.ToStateID, assetEvent.ToStateName, $"Asset event \"{assetEvent.EventTitle}\" to state");
|
|
}
|
|
|
|
foreach (var dependency in backup.Data.AssetDependencies)
|
|
{
|
|
dependency.AssetDependencyID = NewId("AssetDependencies", dependency.AssetDependencyID);
|
|
dependency.SourceAssetID = ExistingOrNew("StoryAssets", dependency.SourceAssetID);
|
|
dependency.TargetAssetID = ExistingOrNew("StoryAssets", dependency.TargetAssetID);
|
|
dependency.AssetDependencyTypeID = ResolveLookupID(assetDependencyTypeIds, dependency.AssetDependencyTypeID, dependency.DependencyTypeName, $"Asset dependency {dependency.AssetDependencyID} type");
|
|
dependency.SourceRequiredStateID = ResolveMappedOrLookupID("AssetStates", assetStateIds, dependency.SourceRequiredStateID, dependency.SourceRequiredStateName, $"Asset dependency {dependency.AssetDependencyID} source state");
|
|
dependency.TargetBlockedStateID = ResolveMappedOrLookupID("AssetStates", assetStateIds, dependency.TargetBlockedStateID, dependency.TargetBlockedStateName, $"Asset dependency {dependency.AssetDependencyID} target state");
|
|
}
|
|
|
|
foreach (var custodyEvent in backup.Data.AssetCustodyEvents)
|
|
{
|
|
custodyEvent.AssetCustodyEventID = NewId("AssetCustodyEvents", custodyEvent.AssetCustodyEventID);
|
|
custodyEvent.StoryAssetID = ExistingOrNew("StoryAssets", custodyEvent.StoryAssetID);
|
|
custodyEvent.SceneID = ExistingOrNew("Scenes", custodyEvent.SceneID);
|
|
custodyEvent.AssetCustodyEventTypeID = ResolveLookupID(assetCustodyEventTypeIds, custodyEvent.AssetCustodyEventTypeID, custodyEvent.CustodyEventTypeName, $"Asset custody event {custodyEvent.AssetCustodyEventID} type");
|
|
}
|
|
|
|
foreach (var custodyCharacter in backup.Data.AssetCustodyCharacters)
|
|
{
|
|
custodyCharacter.AssetCustodyEventCharacterID = NewId("AssetCustodyEventCharacters", custodyCharacter.AssetCustodyEventCharacterID);
|
|
custodyCharacter.AssetCustodyEventID = ExistingOrNew("AssetCustodyEvents", custodyCharacter.AssetCustodyEventID);
|
|
custodyCharacter.CharacterID = ExistingOrNewNullable("Characters", custodyCharacter.CharacterID);
|
|
custodyCharacter.CustodyRoleID = ResolveLookupID(custodyRoleIds, custodyCharacter.CustodyRoleID, custodyCharacter.CustodyRoleName, $"Asset custody character {custodyCharacter.AssetCustodyEventCharacterID} role");
|
|
}
|
|
|
|
foreach (var sceneCharacter in backup.Data.SceneCharacters)
|
|
{
|
|
sceneCharacter.SceneCharacterID = NewId("SceneCharacters", sceneCharacter.SceneCharacterID);
|
|
sceneCharacter.SceneID = ExistingOrNew("Scenes", sceneCharacter.SceneID);
|
|
sceneCharacter.CharacterID = ExistingOrNew("Characters", sceneCharacter.CharacterID);
|
|
sceneCharacter.ProjectID = projectId;
|
|
sceneCharacter.RoleInSceneTypeID = ResolveNullableLookupID(characterRoleTypeIds, sceneCharacter.RoleInSceneTypeID, sceneCharacter.RoleInSceneTypeName, $"Scene character \"{sceneCharacter.CharacterName}\" role");
|
|
sceneCharacter.PresenceTypeID = ResolveNullableLookupID(presenceTypeIds, sceneCharacter.PresenceTypeID, sceneCharacter.PresenceTypeName, $"Scene character \"{sceneCharacter.CharacterName}\" presence");
|
|
sceneCharacter.LocationID = ExistingOrNewNullable("Locations", sceneCharacter.LocationID);
|
|
sceneCharacter.EntryLocationID = ExistingOrNewNullable("Locations", sceneCharacter.EntryLocationID);
|
|
sceneCharacter.ExitLocationID = ExistingOrNewNullable("Locations", sceneCharacter.ExitLocationID);
|
|
}
|
|
|
|
foreach (var attributeEvent in backup.Data.CharacterAttributeEvents)
|
|
{
|
|
attributeEvent.CharacterAttributeEventID = NewId("CharacterAttributeEvents", attributeEvent.CharacterAttributeEventID);
|
|
attributeEvent.CharacterID = ExistingOrNew("Characters", attributeEvent.CharacterID);
|
|
attributeEvent.CharacterAttributeTypeID = ExistingOrNew("CharacterAttributeTypes", attributeEvent.CharacterAttributeTypeID);
|
|
attributeEvent.SceneID = ExistingOrNew("Scenes", attributeEvent.SceneID);
|
|
}
|
|
|
|
foreach (var knowledge in backup.Data.CharacterKnowledge)
|
|
{
|
|
knowledge.CharacterKnowledgeID = NewId("CharacterKnowledge", knowledge.CharacterKnowledgeID);
|
|
knowledge.CharacterID = ExistingOrNew("Characters", knowledge.CharacterID);
|
|
knowledge.StoryAssetID = ExistingOrNewNullable("StoryAssets", knowledge.StoryAssetID);
|
|
knowledge.PlotThreadID = ExistingOrNewNullable("PlotThreads", knowledge.PlotThreadID);
|
|
knowledge.SceneID = ExistingOrNew("Scenes", knowledge.SceneID);
|
|
knowledge.KnowledgeStateID = ResolveLookupID(knowledgeStateIds, knowledge.KnowledgeStateID, knowledge.KnowledgeStateName, $"Character knowledge {knowledge.CharacterKnowledgeID} state");
|
|
knowledge.SourceEventID = ExistingOrNewNullable("ThreadEvents", knowledge.SourceEventID);
|
|
}
|
|
|
|
foreach (var relationship in backup.Data.CharacterRelationships)
|
|
{
|
|
relationship.CharacterRelationshipID = NewId("CharacterRelationships", relationship.CharacterRelationshipID);
|
|
relationship.ProjectID = projectId;
|
|
relationship.CharacterAID = ExistingOrNew("Characters", relationship.CharacterAID);
|
|
relationship.CharacterBID = ExistingOrNew("Characters", relationship.CharacterBID);
|
|
relationship.RelationshipTypeID = ResolveRelationshipTypeID(relationship);
|
|
relationship.StartSceneID = ExistingOrNewNullable("Scenes", relationship.StartSceneID);
|
|
relationship.EndSceneID = ExistingOrNewNullable("Scenes", relationship.EndSceneID);
|
|
relationship.InitialBookID = ExistingOrNewNullable("Books", relationship.InitialBookID);
|
|
relationship.InitialRelationshipStateID = ResolveRelationshipStateID(
|
|
relationship.InitialRelationshipStateID,
|
|
relationship.InitialRelationshipStateName,
|
|
$"Initial relationship {relationship.CharacterRelationshipID} between \"{relationship.CharacterAName}\" and \"{relationship.CharacterBName}\"");
|
|
}
|
|
|
|
foreach (var relationshipEvent in backup.Data.RelationshipEvents)
|
|
{
|
|
relationshipEvent.RelationshipEventID = NewId("RelationshipEvents", relationshipEvent.RelationshipEventID);
|
|
relationshipEvent.CharacterRelationshipID = ExistingOrNew("CharacterRelationships", relationshipEvent.CharacterRelationshipID);
|
|
relationshipEvent.ProjectID = projectId;
|
|
relationshipEvent.CharacterAID = ExistingOrNew("Characters", relationshipEvent.CharacterAID);
|
|
relationshipEvent.CharacterBID = ExistingOrNew("Characters", relationshipEvent.CharacterBID);
|
|
relationshipEvent.SceneID = ExistingOrNew("Scenes", relationshipEvent.SceneID);
|
|
relationshipEvent.RelationshipStateID = ResolveRelationshipStateID(
|
|
relationshipEvent.RelationshipStateID,
|
|
relationshipEvent.RelationshipStateName,
|
|
$"Relationship event {relationshipEvent.RelationshipEventID}") ?? relationshipEvent.RelationshipStateID;
|
|
}
|
|
|
|
foreach (var relationship in backup.Data.LocationRelationships)
|
|
{
|
|
relationship.LocationRelationshipID = NewId("LocationRelationships", relationship.LocationRelationshipID);
|
|
relationship.FromLocationID = ExistingOrNew("Locations", relationship.FromLocationID);
|
|
relationship.ToLocationID = ExistingOrNew("Locations", relationship.ToLocationID);
|
|
relationship.LocationRelationshipTypeID = ResolveLookupID(locationRelationshipTypeIds, relationship.LocationRelationshipTypeID, relationship.RelationshipTypeName, $"Location relationship {relationship.LocationRelationshipID} type");
|
|
}
|
|
|
|
foreach (var sceneAssetLocation in backup.Data.SceneAssetLocations)
|
|
{
|
|
sceneAssetLocation.SceneAssetLocationID = NewId("SceneAssetLocations", sceneAssetLocation.SceneAssetLocationID);
|
|
sceneAssetLocation.SceneID = ExistingOrNew("Scenes", sceneAssetLocation.SceneID);
|
|
sceneAssetLocation.StoryAssetID = ExistingOrNew("StoryAssets", sceneAssetLocation.StoryAssetID);
|
|
sceneAssetLocation.LocationID = ExistingOrNew("Locations", sceneAssetLocation.LocationID);
|
|
}
|
|
|
|
foreach (var occupancy in backup.Data.SceneFloorPlanOccupancy)
|
|
{
|
|
occupancy.SceneFloorPlanOccupancyID = NewId("SceneFloorPlanOccupancy", occupancy.SceneFloorPlanOccupancyID);
|
|
occupancy.SceneID = ExistingOrNew("Scenes", occupancy.SceneID);
|
|
occupancy.FloorPlanID = ExistingOrNew("FloorPlans", occupancy.FloorPlanID);
|
|
occupancy.FloorPlanFloorID = ExistingOrNewNullable("FloorPlanFloors", occupancy.FloorPlanFloorID);
|
|
occupancy.CharacterID = ExistingOrNewNullable("Characters", occupancy.CharacterID);
|
|
occupancy.StoryAssetID = ExistingOrNewNullable("StoryAssets", occupancy.StoryAssetID);
|
|
occupancy.LocationID = ExistingOrNew("Locations", occupancy.LocationID);
|
|
}
|
|
|
|
foreach (var dependency in backup.Data.SceneDependencies)
|
|
{
|
|
dependency.SceneDependencyID = NewId("SceneDependencies", dependency.SceneDependencyID);
|
|
dependency.ProjectID = projectId;
|
|
dependency.SourceSceneID = ExistingOrNew("Scenes", dependency.SourceSceneID);
|
|
dependency.TargetSceneID = ExistingOrNew("Scenes", dependency.TargetSceneID);
|
|
dependency.SceneDependencyTypeID = ResolveLookupID(sceneDependencyTypeIds, dependency.SceneDependencyTypeID, dependency.SceneDependencyTypeName, $"Scene dependency {dependency.SceneDependencyID} type");
|
|
}
|
|
|
|
foreach (var validationRun in backup.Data.ValidationRuns)
|
|
{
|
|
validationRun.ValidationRunID = NewId("ValidationRuns", validationRun.ValidationRunID);
|
|
validationRun.ProjectID = projectId;
|
|
validationRun.BookID = ExistingOrNewNullable("Books", validationRun.BookID);
|
|
}
|
|
|
|
foreach (var warning in backup.Data.ContinuityWarnings)
|
|
{
|
|
warning.ContinuityWarningID = NewId("ContinuityWarnings", warning.ContinuityWarningID);
|
|
warning.ValidationRunID = ExistingOrNewNullable("ValidationRuns", warning.ValidationRunID);
|
|
warning.ProjectID = projectId;
|
|
warning.BookID = ExistingOrNewNullable("Books", warning.BookID);
|
|
warning.ChapterID = ExistingOrNewNullable("Chapters", warning.ChapterID);
|
|
warning.SceneID = ExistingOrNewNullable("Scenes", warning.SceneID);
|
|
warning.EntityID = RemapPolymorphicEntityID(warning.EntityType, warning.EntityID, ExistingOrNewNullable);
|
|
}
|
|
|
|
foreach (var note in backup.Data.SceneNotes)
|
|
{
|
|
note.SceneNoteID = NewId("SceneNotes", note.SceneNoteID);
|
|
note.SceneID = ExistingOrNew("Scenes", note.SceneID);
|
|
note.SceneNoteTypeID = ResolveLookupID(sceneNoteTypeIds, note.SceneNoteTypeID, note.TypeName, $"Scene note \"{note.NoteTitle}\" type");
|
|
}
|
|
|
|
foreach (var workflow in backup.Data.SceneWorkflow)
|
|
{
|
|
workflow.SceneWorkflowID = NewId("SceneWorkflow", workflow.SceneWorkflowID);
|
|
workflow.SceneID = ExistingOrNew("Scenes", workflow.SceneID);
|
|
}
|
|
|
|
foreach (var item in backup.Data.SceneChecklistItems)
|
|
{
|
|
item.SceneChecklistItemID = NewId("SceneChecklistItems", item.SceneChecklistItemID);
|
|
item.SceneID = ExistingOrNew("Scenes", item.SceneID);
|
|
}
|
|
|
|
foreach (var attachment in backup.Data.SceneAttachments)
|
|
{
|
|
attachment.SceneAttachmentID = NewId("SceneAttachments", attachment.SceneAttachmentID);
|
|
attachment.SceneID = ExistingOrNew("Scenes", attachment.SceneID);
|
|
attachment.SceneAttachmentTypeID = ResolveLookupID(sceneAttachmentTypeIds, attachment.SceneAttachmentTypeID, attachment.TypeName, $"Scene attachment \"{attachment.Title}\" type");
|
|
}
|
|
|
|
foreach (var goal in backup.Data.ChapterGoals)
|
|
{
|
|
goal.ChapterGoalID = NewId("ChapterGoals", goal.ChapterGoalID);
|
|
goal.ChapterID = ExistingOrNew("Chapters", goal.ChapterID);
|
|
}
|
|
|
|
foreach (var scenario in backup.Data.Scenarios)
|
|
{
|
|
scenario.ScenarioID = NewId("Scenarios", scenario.ScenarioID);
|
|
scenario.ProjectID = projectId;
|
|
scenario.BookID = ExistingOrNewNullable("Books", scenario.BookID);
|
|
}
|
|
|
|
foreach (var order in backup.Data.ScenarioSceneOrder)
|
|
{
|
|
order.ScenarioSceneOrderID = NewId("ScenarioSceneOrder", order.ScenarioSceneOrderID);
|
|
order.ScenarioID = ExistingOrNew("Scenarios", order.ScenarioID);
|
|
order.SceneID = ExistingOrNew("Scenes", order.SceneID);
|
|
order.ProposedChapterID = ExistingOrNewNullable("Chapters", order.ProposedChapterID);
|
|
}
|
|
|
|
foreach (var run in backup.Data.ScenarioValidationRuns)
|
|
{
|
|
run.ScenarioValidationRunID = NewId("ScenarioValidationRuns", run.ScenarioValidationRunID);
|
|
run.ScenarioID = ExistingOrNew("Scenarios", run.ScenarioID);
|
|
}
|
|
|
|
foreach (var warning in backup.Data.ScenarioWarnings)
|
|
{
|
|
warning.ScenarioWarningID = NewId("ScenarioWarnings", warning.ScenarioWarningID);
|
|
warning.ScenarioValidationRunID = ExistingOrNew("ScenarioValidationRuns", warning.ScenarioValidationRunID);
|
|
warning.ScenarioID = ExistingOrNew("Scenarios", warning.ScenarioID);
|
|
warning.SceneID = ExistingOrNewNullable("Scenes", warning.SceneID);
|
|
warning.EntityID = RemapPolymorphicEntityID(warning.EntityType, warning.EntityID, ExistingOrNewNullable);
|
|
}
|
|
}
|
|
|
|
private static int? RemapPolymorphicEntityID(string? entityType, int? entityId, Func<string, int?, int?> remap)
|
|
{
|
|
if (!entityId.HasValue || string.IsNullOrWhiteSpace(entityType))
|
|
{
|
|
return entityId;
|
|
}
|
|
|
|
var normalized = entityType.Trim().ToLowerInvariant();
|
|
var table = normalized switch
|
|
{
|
|
"project" => null,
|
|
"book" => "Books",
|
|
"chapter" => "Chapters",
|
|
"scene" => "Scenes",
|
|
"character" => "Characters",
|
|
"location" => "Locations",
|
|
"plotline" or "plot line" => "PlotLines",
|
|
"plotthread" or "plot thread" => "PlotThreads",
|
|
"storyasset" or "story asset" or "asset" => "StoryAssets",
|
|
"relationship" or "characterrelationship" or "character relationship" => "CharacterRelationships",
|
|
"thread event" or "threadevent" => "ThreadEvents",
|
|
"asset event" or "assetevent" => "AssetEvents",
|
|
"floorplan" or "floor plan" => "FloorPlans",
|
|
"floorplanfloor" or "floor plan floor" => "FloorPlanFloors",
|
|
"floorplanblock" or "floor plan block" => "FloorPlanBlocks",
|
|
"floorplantransition" or "floor plan transition" => "FloorPlanTransitions",
|
|
"scenefloorplanoccupancy" or "scene floor plan occupancy" => "SceneFloorPlanOccupancy",
|
|
_ => null
|
|
};
|
|
|
|
return table is null ? entityId : remap(table, entityId);
|
|
}
|
|
|
|
private static ImportResult BuildDryRunResult(ImportPreview preview)
|
|
{
|
|
var unresolvedThreadEvents = preview.UnresolvedThreadEventReferences.Count;
|
|
var unresolvedAssetStates = preview.UnresolvedAssetReferences.Count(x => x.Contains("state", StringComparison.OrdinalIgnoreCase));
|
|
var unresolvedAssetEvents = preview.UnresolvedAssetReferences.Count(x => x.Contains("event", StringComparison.OrdinalIgnoreCase));
|
|
|
|
return new ImportResult
|
|
{
|
|
Succeeded = true,
|
|
IsDryRun = true,
|
|
ProjectID = null,
|
|
ProjectName = preview.Package?.Project.Title ?? "Dry run",
|
|
BooksCreated = preview.BookCount,
|
|
ChaptersCreated = preview.ChapterCount,
|
|
ScenesCreated = preview.SceneCount,
|
|
CharactersCreated = preview.CharacterCount,
|
|
LocationsCreated = preview.LocationCount,
|
|
SceneCharacterAppearancesCreated = preview.SceneCharacterAppearanceCount,
|
|
PlotLinesCreated = preview.PlotLineCount,
|
|
PlotThreadsCreated = preview.PlotThreadCount,
|
|
ThreadEventsCreated = Math.Max(0, preview.ThreadEventCount - unresolvedThreadEvents),
|
|
StoryAssetsCreated = preview.StoryAssetCount,
|
|
AssetStatesCreated = Math.Max(0, preview.AssetStateCount - unresolvedAssetStates),
|
|
AssetEventsCreated = Math.Max(0, preview.AssetEventCount - unresolvedAssetEvents),
|
|
AssetCustodyEventsCreated = preview.ResolvedAssetCustodyCount,
|
|
SceneDependenciesCreated = preview.ResolvedSceneDependencyCount,
|
|
ErrorCount = preview.ErrorCount,
|
|
WarningCount = preview.WarningCount,
|
|
AutoCorrectionCount = preview.AutoCorrectionCount,
|
|
SkippedItemCount = preview.SkippedItemCount,
|
|
UnresolvedReferenceCount = preview.UnresolvedReferenceCount,
|
|
AutoCorrections = preview.AutoCorrections.Concat(preview.Validation.AutoCorrections).Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
|
|
SkippedOptionalFields = preview.SkippedItems.Concat(preview.Validation.SkippedItems).Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
|
|
UnresolvedWarnings = preview.UnresolvedThreadEventReferences
|
|
.Concat(preview.UnresolvedAssetReferences)
|
|
.Concat(preview.UnresolvedSceneDependencyReferences)
|
|
.ToList(),
|
|
Message = "Dry run complete. No data was saved."
|
|
};
|
|
}
|
|
|
|
private static ImportValidationResult Validate(PlotLineImportPackage package)
|
|
{
|
|
var result = new ImportValidationResult();
|
|
|
|
if (string.IsNullOrWhiteSpace(package.Project.Title))
|
|
{
|
|
result.Errors.Add("Project title is required.");
|
|
}
|
|
else if (package.Project.Title.Trim().Length > TitleMaxLength)
|
|
{
|
|
result.Errors.Add($"Project title is longer than {TitleMaxLength} characters.");
|
|
}
|
|
|
|
ValidateLargeText(result, "Project description", package.Project.Description);
|
|
ValidateLargeText(result, "Project notes", package.Project.Notes);
|
|
ValidateCharacters(package, result);
|
|
ValidateLocations(package, result);
|
|
ValidatePlotLines(package, result);
|
|
ValidateStoryAssets(package, result);
|
|
ValidateSceneDependencies(package, result);
|
|
|
|
if (string.IsNullOrWhiteSpace(package.PackageVersion))
|
|
{
|
|
result.Warnings.Add("packageVersion is missing. Version 1.0 is expected for Phase 1F.");
|
|
}
|
|
else if (package.PackageVersion.Trim() != "1.0")
|
|
{
|
|
result.Warnings.Add($"packageVersion \"{package.PackageVersion}\" is not the current supported version. The importer will continue if the structure is valid.");
|
|
}
|
|
|
|
if (package.Books.Count == 0)
|
|
{
|
|
result.Warnings.Add("No books were supplied. The import will create an empty project.");
|
|
}
|
|
|
|
var totalChapters = package.Books.Sum(book => book.Chapters.Count);
|
|
var totalScenes = package.Books.Sum(book => book.Chapters.Sum(chapter => chapter.Scenes.Count));
|
|
if (package.Books.Count > SuspiciousBookCount || totalChapters > SuspiciousChapterCount || totalScenes > SuspiciousSceneCount)
|
|
{
|
|
result.Warnings.Add($"This is a large import ({package.Books.Count} books, {totalChapters} chapters, {totalScenes} scenes). Review the preview carefully before importing.");
|
|
}
|
|
|
|
foreach (var group in package.Books.GroupBy(x => x.SeriesOrder).Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"Book order {group.Key} is used more than once.");
|
|
}
|
|
|
|
foreach (var duplicateTitle in package.Books
|
|
.Where(book => !string.IsNullOrWhiteSpace(book.Title))
|
|
.GroupBy(book => NormalizeNameKey(book.Title), StringComparer.OrdinalIgnoreCase)
|
|
.Where(group => group.Count() > 1))
|
|
{
|
|
result.Warnings.Add($"Book title \"{duplicateTitle.First().Title.Trim()}\" appears more than once.");
|
|
}
|
|
|
|
for (var bookIndex = 0; bookIndex < package.Books.Count; bookIndex++)
|
|
{
|
|
var book = package.Books[bookIndex];
|
|
var bookLabel = string.IsNullOrWhiteSpace(book.Title) ? $"Book {bookIndex + 1}" : book.Title;
|
|
if (string.IsNullOrWhiteSpace(book.Title))
|
|
{
|
|
result.Errors.Add($"Book {bookIndex + 1} is missing a title.");
|
|
}
|
|
else if (FormatBookTitle(book).Length > TitleMaxLength)
|
|
{
|
|
result.Errors.Add($"{bookLabel} title is longer than {TitleMaxLength} characters after combining title and subtitle.");
|
|
}
|
|
|
|
if (book.SeriesOrder <= 0)
|
|
{
|
|
result.Errors.Add($"{bookLabel} is missing a valid seriesOrder.");
|
|
}
|
|
|
|
if (book.Chapters.Count == 0)
|
|
{
|
|
result.Warnings.Add($"{bookLabel} has no chapters.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{bookLabel} description", book.Description);
|
|
ValidateLargeText(result, $"{bookLabel} notes", book.Notes);
|
|
|
|
foreach (var group in book.Chapters.GroupBy(x => x.Order).Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"{bookLabel} has duplicate chapter order {group.Key}.");
|
|
}
|
|
|
|
foreach (var duplicateTitle in book.Chapters
|
|
.Where(chapter => !string.IsNullOrWhiteSpace(chapter.Title))
|
|
.GroupBy(chapter => NormalizeNameKey(chapter.Title), StringComparer.OrdinalIgnoreCase)
|
|
.Where(group => group.Count() > 1))
|
|
{
|
|
result.Warnings.Add($"{bookLabel} has duplicate chapter title \"{duplicateTitle.First().Title.Trim()}\".");
|
|
}
|
|
|
|
for (var chapterIndex = 0; chapterIndex < book.Chapters.Count; chapterIndex++)
|
|
{
|
|
var chapter = book.Chapters[chapterIndex];
|
|
var chapterLabel = string.IsNullOrWhiteSpace(chapter.Title) ? $"chapter {chapterIndex + 1}" : chapter.Title;
|
|
if (string.IsNullOrWhiteSpace(chapter.Title))
|
|
{
|
|
result.Errors.Add($"{bookLabel} chapter {chapterIndex + 1} is missing a title.");
|
|
}
|
|
else if (chapter.Title.Trim().Length > TitleMaxLength)
|
|
{
|
|
result.Errors.Add($"{bookLabel} / {chapterLabel} title is longer than {TitleMaxLength} characters.");
|
|
}
|
|
|
|
if (chapter.Order <= 0)
|
|
{
|
|
result.Errors.Add($"{bookLabel} / {chapterLabel} is missing a valid chapter order.");
|
|
}
|
|
|
|
if (chapter.Scenes.Count == 0)
|
|
{
|
|
result.Warnings.Add($"{bookLabel} / {chapterLabel} has no scenes.");
|
|
}
|
|
else if (chapter.Scenes.Count == 1)
|
|
{
|
|
result.Warnings.Add($"{bookLabel} / {chapterLabel} has only one scene. This may be intentional, but is worth checking in manuscript-derived data.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{bookLabel} / {chapterLabel} summary", chapter.Summary);
|
|
ValidateLargeText(result, $"{bookLabel} / {chapterLabel} notes", chapter.Notes);
|
|
|
|
foreach (var group in chapter.Scenes.GroupBy(x => x.Order).Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"{bookLabel} / {chapterLabel} has duplicate scene order {group.Key}.");
|
|
}
|
|
|
|
foreach (var duplicateTitle in chapter.Scenes
|
|
.Where(scene => !string.IsNullOrWhiteSpace(scene.Title))
|
|
.GroupBy(scene => NormalizeNameKey(scene.Title), StringComparer.OrdinalIgnoreCase)
|
|
.Where(group => group.Count() > 1))
|
|
{
|
|
result.Warnings.Add($"{bookLabel} / {chapterLabel} has duplicate scene title \"{duplicateTitle.First().Title.Trim()}\".");
|
|
}
|
|
|
|
for (var sceneIndex = 0; sceneIndex < chapter.Scenes.Count; sceneIndex++)
|
|
{
|
|
var scene = chapter.Scenes[sceneIndex];
|
|
var sceneLabel = string.IsNullOrWhiteSpace(scene.Title) ? $"scene {sceneIndex + 1}" : scene.Title;
|
|
if (string.IsNullOrWhiteSpace(scene.Title))
|
|
{
|
|
result.Errors.Add($"{bookLabel} / {chapterLabel} scene {sceneIndex + 1} is missing a title.");
|
|
}
|
|
else if (scene.Title.Trim().Length > TitleMaxLength)
|
|
{
|
|
result.Errors.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} title is longer than {TitleMaxLength} characters.");
|
|
}
|
|
|
|
if (scene.Order <= 0)
|
|
{
|
|
result.Errors.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} is missing a valid scene order.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(scene.Summary))
|
|
{
|
|
result.Warnings.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} has an empty scene summary.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(scene.DateTimeText) && scene.DateTimeText.Trim().Length > RelativeTimeMaxLength)
|
|
{
|
|
result.Warnings.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} dateTimeText is longer than {RelativeTimeMaxLength} characters and will be truncated.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{bookLabel} / {chapterLabel} / {sceneLabel} summary", scene.Summary);
|
|
ValidateLargeText(result, $"{bookLabel} / {chapterLabel} / {sceneLabel} notes", scene.Notes);
|
|
ValidateLargeText(result, $"{bookLabel} / {chapterLabel} / {sceneLabel} purposeText", scene.PurposeText);
|
|
ValidateLargeText(result, $"{bookLabel} / {chapterLabel} / {sceneLabel} outcomeText", scene.OutcomeText);
|
|
|
|
foreach (var duplicate in scene.SceneCharacters
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.CharacterName))
|
|
.GroupBy(x => NormalizeNameKey(x.CharacterName), StringComparer.OrdinalIgnoreCase)
|
|
.Where(x => x.Count() > 1))
|
|
{
|
|
var duplicateName = duplicate.First().CharacterName.Trim();
|
|
result.Warnings.Add($"{bookLabel} / {chapterLabel} / {sceneLabel} lists {duplicateName} more than once in sceneCharacters. Duplicate appearances will be skipped.");
|
|
result.SkippedItems.Add($"Duplicate scene appearance skipped: {bookLabel} / {chapterLabel} / {sceneLabel} / {duplicateName}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var book in package.Books.Where(book => book.SeriesOrder > 1 && !package.SceneDependencies.Any(dependency => dependency.ToScene?.BookOrder == book.SeriesOrder || dependency.FromScene?.BookOrder == book.SeriesOrder)))
|
|
{
|
|
result.Warnings.Add($"{book.Title} is in the middle of the series but has no scene dependencies.");
|
|
}
|
|
|
|
foreach (var name in FindUnmatchedPovNames(package))
|
|
{
|
|
result.Warnings.Add($"POV character \"{name}\" does not match a top-level imported character. The text will be preserved in scene notes.");
|
|
}
|
|
|
|
foreach (var name in FindUnmatchedLocationNames(package))
|
|
{
|
|
result.Warnings.Add($"Scene location \"{name}\" does not match a top-level imported location. The text will be preserved in scene notes.");
|
|
}
|
|
|
|
foreach (var name in FindUnmatchedSceneCharacterNames(package))
|
|
{
|
|
result.Warnings.Add($"Scene character \"{name}\" does not match a top-level imported character and will not create an appearance.");
|
|
}
|
|
|
|
foreach (var reference in FindUnresolvedThreadEventReferences(package))
|
|
{
|
|
result.Warnings.Add($"Thread event scene reference could not be resolved and will be skipped: {reference}");
|
|
}
|
|
|
|
foreach (var reference in FindUnresolvedAssetReferences(package))
|
|
{
|
|
result.Warnings.Add($"Asset state/event scene reference could not be resolved and will be skipped: {reference}");
|
|
}
|
|
|
|
foreach (var name in FindUnmatchedAssetOwnerNames(package))
|
|
{
|
|
result.Warnings.Add($"Story asset owner \"{name}\" does not match a top-level imported character.");
|
|
}
|
|
|
|
foreach (var name in FindUnmatchedAssetLocationNames(package))
|
|
{
|
|
result.Warnings.Add($"Story asset location \"{name}\" does not match a top-level imported location.");
|
|
}
|
|
|
|
foreach (var reference in FindUnresolvedSceneDependencyReferences(package))
|
|
{
|
|
result.Warnings.Add($"Scene dependency reference could not be resolved and will be skipped: {reference}");
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static void ValidateSceneDependencies(PlotLineImportPackage package, ImportValidationResult result)
|
|
{
|
|
foreach (var duplicate in package.SceneDependencies
|
|
.Where(dependency => HasSceneOrderReference(dependency.FromScene) && HasSceneOrderReference(dependency.ToScene))
|
|
.GroupBy(SceneDependencyKey, StringComparer.OrdinalIgnoreCase)
|
|
.Where(group => group.Count() > 1))
|
|
{
|
|
result.Warnings.Add($"Duplicate scene dependency will be imported once: {duplicate.Key}.");
|
|
}
|
|
|
|
for (var index = 0; index < package.SceneDependencies.Count; index++)
|
|
{
|
|
var dependency = package.SceneDependencies[index];
|
|
var label = $"Scene dependency {index + 1}";
|
|
|
|
if (dependency.FromScene is null)
|
|
{
|
|
result.Warnings.Add($"{label} is missing fromScene and will be skipped.");
|
|
}
|
|
else if (!HasSceneOrderReference(dependency.FromScene) && string.IsNullOrWhiteSpace(dependency.FromScene.SceneRef))
|
|
{
|
|
result.Warnings.Add($"{label} fromScene is missing bookOrder/chapterOrder/sceneOrder.");
|
|
}
|
|
|
|
if (dependency.ToScene is null)
|
|
{
|
|
result.Warnings.Add($"{label} is missing toScene and will be skipped.");
|
|
}
|
|
else if (!HasSceneOrderReference(dependency.ToScene) && string.IsNullOrWhiteSpace(dependency.ToScene.SceneRef))
|
|
{
|
|
result.Warnings.Add($"{label} toScene is missing bookOrder/chapterOrder/sceneOrder.");
|
|
}
|
|
|
|
if (HasSceneOrderReference(dependency.FromScene) && HasSceneOrderReference(dependency.ToScene)
|
|
&& SceneReferenceKey(dependency.FromScene!) == SceneReferenceKey(dependency.ToScene!))
|
|
{
|
|
result.Warnings.Add($"{label} links a scene to itself and will be skipped.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(dependency.DependencyType) && dependency.DependencyType.Trim().Length > 100)
|
|
{
|
|
result.Warnings.Add($"{label} dependencyType is longer than 100 characters and may not match an existing dependency type.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(dependency.Strength) && !IsSupportedDependencyStrength(dependency.Strength))
|
|
{
|
|
result.Warnings.Add($"{label} strength \"{dependency.Strength.Trim()}\" is not a recognized hard/soft value. It will be preserved in the description.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{label} description", dependency.Description);
|
|
ValidateLargeText(result, $"{label} notes", dependency.Notes);
|
|
}
|
|
}
|
|
|
|
private static void ValidateStoryAssets(PlotLineImportPackage package, ImportValidationResult result)
|
|
{
|
|
foreach (var group in package.StoryAssets
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
|
.GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase)
|
|
.Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"Story asset \"{group.Key}\" appears more than once in the import package.");
|
|
}
|
|
|
|
for (var assetIndex = 0; assetIndex < package.StoryAssets.Count; assetIndex++)
|
|
{
|
|
var asset = package.StoryAssets[assetIndex];
|
|
var assetLabel = string.IsNullOrWhiteSpace(asset.Name) ? $"Story asset {assetIndex + 1}" : asset.Name.Trim();
|
|
if (string.IsNullOrWhiteSpace(asset.Name))
|
|
{
|
|
result.Errors.Add($"Story asset {assetIndex + 1} is missing a name.");
|
|
}
|
|
|
|
if (DisplayName(asset.DisplayName, asset.Name).Length > TitleMaxLength)
|
|
{
|
|
result.Errors.Add($"{assetLabel} name/displayName is longer than {TitleMaxLength} characters.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{assetLabel} description", asset.Description);
|
|
ValidateLargeText(result, $"{assetLabel} notes", asset.Notes);
|
|
|
|
if (asset.States.Count == 0 && asset.Events.Count == 0)
|
|
{
|
|
result.Warnings.Add($"{assetLabel} has no states or events.");
|
|
}
|
|
|
|
foreach (var duplicate in asset.States
|
|
.Where(HasSceneOrderReference)
|
|
.GroupBy(AssetStateSceneKey)
|
|
.Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"{assetLabel} has duplicate states for scene {duplicate.Key}.");
|
|
}
|
|
|
|
foreach (var duplicate in asset.Events
|
|
.Where(HasSceneOrderReference)
|
|
.GroupBy(AssetEventSceneKey)
|
|
.Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"{assetLabel} has duplicate events for scene {duplicate.Key}.");
|
|
}
|
|
|
|
for (var stateIndex = 0; stateIndex < asset.States.Count; stateIndex++)
|
|
{
|
|
var state = asset.States[stateIndex];
|
|
var stateLabel = string.IsNullOrWhiteSpace(state.StateName) ? $"State {stateIndex + 1}" : state.StateName.Trim();
|
|
if (string.IsNullOrWhiteSpace(state.StateName) && string.IsNullOrWhiteSpace(state.Description))
|
|
{
|
|
result.Errors.Add($"{assetLabel} state {stateIndex + 1} needs stateName or description.");
|
|
}
|
|
|
|
if (!HasSceneOrderReference(state) && string.IsNullOrWhiteSpace(state.SceneRef))
|
|
{
|
|
result.Warnings.Add($"{assetLabel} / {stateLabel} is missing bookOrder/chapterOrder/sceneOrder.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(state.StateName) && state.StateName.Trim().Length > 100)
|
|
{
|
|
result.Warnings.Add($"{assetLabel} / {stateLabel} stateName is longer than 100 characters and may not match an existing asset state.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{assetLabel} / {stateLabel} description", state.Description);
|
|
ValidateLargeText(result, $"{assetLabel} / {stateLabel} notes", state.Notes);
|
|
}
|
|
|
|
for (var eventIndex = 0; eventIndex < asset.Events.Count; eventIndex++)
|
|
{
|
|
var assetEvent = asset.Events[eventIndex];
|
|
var eventLabel = string.IsNullOrWhiteSpace(assetEvent.Title) ? $"Event {eventIndex + 1}" : assetEvent.Title.Trim();
|
|
if (string.IsNullOrWhiteSpace(assetEvent.Title) && string.IsNullOrWhiteSpace(assetEvent.Summary))
|
|
{
|
|
result.Errors.Add($"{assetLabel} event {eventIndex + 1} needs a title or summary.");
|
|
}
|
|
|
|
if (!HasSceneOrderReference(assetEvent) && string.IsNullOrWhiteSpace(assetEvent.SceneRef))
|
|
{
|
|
result.Warnings.Add($"{assetLabel} / {eventLabel} is missing bookOrder/chapterOrder/sceneOrder.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(assetEvent.Title) && assetEvent.Title.Trim().Length > TitleMaxLength)
|
|
{
|
|
result.Errors.Add($"{assetLabel} / {eventLabel} title is longer than {TitleMaxLength} characters.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{assetLabel} / {eventLabel} summary", assetEvent.Summary);
|
|
ValidateLargeText(result, $"{assetLabel} / {eventLabel} notes", assetEvent.Notes);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void ValidatePlotLines(PlotLineImportPackage package, ImportValidationResult result)
|
|
{
|
|
foreach (var duplicateThread in package.PlotLines
|
|
.SelectMany(plotLine => plotLine.Threads)
|
|
.Where(thread => !string.IsNullOrWhiteSpace(thread.Name))
|
|
.GroupBy(thread => NormalizeNameKey(thread.Name), StringComparer.OrdinalIgnoreCase)
|
|
.Where(group => group.Count() > 1))
|
|
{
|
|
result.Warnings.Add($"Plot thread name \"{duplicateThread.First().Name.Trim()}\" appears in more than one place.");
|
|
}
|
|
|
|
foreach (var group in package.PlotLines
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
|
.GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase)
|
|
.Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"Plot line \"{group.Key}\" appears more than once in the import package.");
|
|
}
|
|
|
|
for (var plotIndex = 0; plotIndex < package.PlotLines.Count; plotIndex++)
|
|
{
|
|
var plotLine = package.PlotLines[plotIndex];
|
|
var plotLabel = string.IsNullOrWhiteSpace(plotLine.Name) ? $"Plot line {plotIndex + 1}" : plotLine.Name.Trim();
|
|
if (string.IsNullOrWhiteSpace(plotLine.Name))
|
|
{
|
|
result.Errors.Add($"Plot line {plotIndex + 1} is missing a name.");
|
|
}
|
|
|
|
if (DisplayName(plotLine.DisplayName, plotLine.Name).Length > TitleMaxLength)
|
|
{
|
|
result.Errors.Add($"{plotLabel} name/displayName is longer than {TitleMaxLength} characters.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{plotLabel} description", plotLine.Description);
|
|
ValidateLargeText(result, $"{plotLabel} notes", plotLine.Notes);
|
|
|
|
foreach (var group in plotLine.Threads
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
|
.GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase)
|
|
.Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"{plotLabel} has duplicate thread \"{group.Key}\".");
|
|
}
|
|
|
|
for (var threadIndex = 0; threadIndex < plotLine.Threads.Count; threadIndex++)
|
|
{
|
|
var thread = plotLine.Threads[threadIndex];
|
|
var threadLabel = string.IsNullOrWhiteSpace(thread.Name) ? $"Thread {threadIndex + 1}" : thread.Name.Trim();
|
|
if (string.IsNullOrWhiteSpace(thread.Name))
|
|
{
|
|
result.Errors.Add($"{plotLabel} thread {threadIndex + 1} is missing a name.");
|
|
}
|
|
|
|
if (DisplayName(thread.DisplayName, thread.Name).Length > TitleMaxLength)
|
|
{
|
|
result.Errors.Add($"{plotLabel} / {threadLabel} name/displayName is longer than {TitleMaxLength} characters.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{plotLabel} / {threadLabel} description", thread.Description);
|
|
ValidateLargeText(result, $"{plotLabel} / {threadLabel} notes", thread.Notes);
|
|
|
|
foreach (var duplicate in thread.Events
|
|
.Where(HasSceneOrderReference)
|
|
.GroupBy(EventSceneKey)
|
|
.Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"{plotLabel} / {threadLabel} has duplicate events for scene {duplicate.Key}.");
|
|
}
|
|
|
|
for (var eventIndex = 0; eventIndex < thread.Events.Count; eventIndex++)
|
|
{
|
|
var threadEvent = thread.Events[eventIndex];
|
|
var eventLabel = string.IsNullOrWhiteSpace(threadEvent.Title) ? $"Event {eventIndex + 1}" : threadEvent.Title.Trim();
|
|
if (!HasSceneOrderReference(threadEvent) && string.IsNullOrWhiteSpace(threadEvent.SceneRef))
|
|
{
|
|
result.Warnings.Add($"{plotLabel} / {threadLabel} / {eventLabel} is missing bookOrder/chapterOrder/sceneOrder.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(threadEvent.Title) && string.IsNullOrWhiteSpace(threadEvent.Summary))
|
|
{
|
|
result.Errors.Add($"{plotLabel} / {threadLabel} event {eventIndex + 1} needs a title or summary.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(threadEvent.Title) && threadEvent.Title.Trim().Length > TitleMaxLength)
|
|
{
|
|
result.Errors.Add($"{plotLabel} / {threadLabel} / {eventLabel} title is longer than {TitleMaxLength} characters.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{plotLabel} / {threadLabel} / {eventLabel} summary", threadEvent.Summary);
|
|
ValidateLargeText(result, $"{plotLabel} / {threadLabel} / {eventLabel} impact", threadEvent.Impact);
|
|
ValidateLargeText(result, $"{plotLabel} / {threadLabel} / {eventLabel} notes", threadEvent.Notes);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void ValidateCharacters(PlotLineImportPackage package, ImportValidationResult result)
|
|
{
|
|
if (package.Characters.Any(character => character.Importance.HasValue || !string.IsNullOrWhiteSpace(character.Role)))
|
|
{
|
|
result.Warnings.Add("Character role/importance will be mapped to Story importance for timeline grouping.");
|
|
}
|
|
|
|
foreach (var group in package.Characters
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
|
.GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase)
|
|
.Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"Character \"{group.Key}\" appears more than once in the import package.");
|
|
}
|
|
|
|
for (var index = 0; index < package.Characters.Count; index++)
|
|
{
|
|
var character = package.Characters[index];
|
|
var label = string.IsNullOrWhiteSpace(character.Name) ? $"Character {index + 1}" : character.Name.Trim();
|
|
if (string.IsNullOrWhiteSpace(character.Name))
|
|
{
|
|
result.Errors.Add($"Character {index + 1} is missing a name.");
|
|
}
|
|
|
|
if (DisplayName(character.DisplayName, character.Name).Length > TitleMaxLength)
|
|
{
|
|
result.Errors.Add($"{label} name/displayName is longer than {TitleMaxLength} characters.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(character.ShortName) && character.ShortName.Trim().Length > 100)
|
|
{
|
|
result.Errors.Add($"{label} shortName is longer than 100 characters.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{label} description", character.Description);
|
|
ValidateLargeText(result, $"{label} notes", character.Notes);
|
|
}
|
|
}
|
|
|
|
private static void ValidateLocations(PlotLineImportPackage package, ImportValidationResult result)
|
|
{
|
|
var names = package.Locations
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
|
.Select(x => x.Name.Trim())
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var group in package.Locations
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.Name))
|
|
.GroupBy(x => x.Name.Trim(), StringComparer.OrdinalIgnoreCase)
|
|
.Where(x => x.Count() > 1))
|
|
{
|
|
result.Errors.Add($"Location \"{group.Key}\" appears more than once in the import package.");
|
|
}
|
|
|
|
for (var index = 0; index < package.Locations.Count; index++)
|
|
{
|
|
var location = package.Locations[index];
|
|
var label = string.IsNullOrWhiteSpace(location.Name) ? $"Location {index + 1}" : location.Name.Trim();
|
|
if (string.IsNullOrWhiteSpace(location.Name))
|
|
{
|
|
result.Errors.Add($"Location {index + 1} is missing a name.");
|
|
}
|
|
|
|
if (DisplayName(location.DisplayName, location.Name).Length > TitleMaxLength)
|
|
{
|
|
result.Errors.Add($"{label} name/displayName is longer than {TitleMaxLength} characters.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(location.Type) && location.Type.Trim().Length > 100)
|
|
{
|
|
result.Warnings.Add($"{label} location type is longer than 100 characters and may not match an existing type.");
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(location.ParentLocationName) && !names.Contains(location.ParentLocationName.Trim()))
|
|
{
|
|
result.Warnings.Add($"{label} references parentLocationName \"{location.ParentLocationName.Trim()}\", but that location is not in this package. It will be imported without a parent.");
|
|
}
|
|
|
|
ValidateLargeText(result, $"{label} description", location.Description);
|
|
ValidateLargeText(result, $"{label} notes", location.Notes);
|
|
}
|
|
}
|
|
|
|
private static void AddMetadataWarnings(PlotLineImportPackage package, ImportValidationResult validation)
|
|
{
|
|
var hasPov = package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName))));
|
|
var hasLocation = package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.LocationName))));
|
|
|
|
if (hasPov)
|
|
{
|
|
validation.Warnings.Add("POV character names that match top-level characters will be linked to scenes. Unmatched POV names stay as plain text notes.");
|
|
}
|
|
|
|
if (hasLocation)
|
|
{
|
|
validation.Warnings.Add("Scene location names that match top-level locations will be linked as primary locations. Unmatched location names stay as plain text notes.");
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyList<ImportBookPreview> BuildBookPreviews(PlotLineImportPackage package)
|
|
=> package.Books
|
|
.OrderBy(x => x.SeriesOrder)
|
|
.Select(book => new ImportBookPreview
|
|
{
|
|
Book = book,
|
|
ChapterPreviews = book.Chapters
|
|
.OrderBy(x => x.Order)
|
|
.Select(chapter => new ImportChapterPreview { Chapter = chapter })
|
|
.ToList()
|
|
})
|
|
.ToList();
|
|
|
|
private static IReadOnlyList<string> FindUnmatchedPovNames(PlotLineImportPackage package)
|
|
{
|
|
var characterLookup = BuildCharacterLookup(package);
|
|
return package.Books
|
|
.SelectMany(book => book.Chapters)
|
|
.SelectMany(chapter => chapter.Scenes)
|
|
.Select(scene => scene.PovCharacterName?.Trim())
|
|
.Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(characterLookup, ResolveAlias(name, package.Aliases?.Characters)))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(name => name)
|
|
.ToList()!;
|
|
}
|
|
|
|
private static IReadOnlyList<string> FindUnmatchedLocationNames(PlotLineImportPackage package)
|
|
{
|
|
var locationLookup = BuildLocationLookup(package);
|
|
return package.Books
|
|
.SelectMany(book => book.Chapters)
|
|
.SelectMany(chapter => chapter.Scenes)
|
|
.Select(scene => scene.LocationName?.Trim())
|
|
.Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(locationLookup, ResolveAlias(name, package.Aliases?.Locations)))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(name => name)
|
|
.ToList()!;
|
|
}
|
|
|
|
private static IReadOnlyList<string> FindUnmatchedSceneCharacterNames(PlotLineImportPackage package)
|
|
{
|
|
var characterLookup = BuildCharacterLookup(package);
|
|
return package.Books
|
|
.SelectMany(book => book.Chapters)
|
|
.SelectMany(chapter => chapter.Scenes)
|
|
.SelectMany(scene => scene.SceneCharacters)
|
|
.Select(sceneCharacter => sceneCharacter.CharacterName?.Trim())
|
|
.Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(characterLookup, ResolveAlias(name, package.Aliases?.Characters)))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(name => name)
|
|
.ToList()!;
|
|
}
|
|
|
|
private static IReadOnlyList<string> FindUnresolvedThreadEventReferences(PlotLineImportPackage package)
|
|
{
|
|
var sceneKeys = package.Books
|
|
.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => $"{book.SeriesOrder}/{chapter.Order}/{scene.Order}")))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
return package.PlotLines
|
|
.SelectMany(plotLine => plotLine.Threads.SelectMany(thread => thread.Events.Select((threadEvent, index) => new
|
|
{
|
|
PlotLineName = DisplayName(plotLine.DisplayName, plotLine.Name),
|
|
ThreadName = DisplayName(thread.DisplayName, thread.Name),
|
|
EventTitle = string.IsNullOrWhiteSpace(threadEvent.Title) ? $"Event {index + 1}" : threadEvent.Title.Trim(),
|
|
Event = threadEvent
|
|
})))
|
|
.Where(x => !HasSceneOrderReference(x.Event) || !sceneKeys.Contains(EventSceneKey(x.Event)))
|
|
.Select(x => $"{x.PlotLineName} / {x.ThreadName} / {x.EventTitle} -> {EventSceneKey(x.Event)}. {SceneReferenceDiagnostic(package, EventSceneKey(x.Event))}")
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(x => x)
|
|
.ToList();
|
|
}
|
|
|
|
private static IReadOnlyList<string> FindUnresolvedAssetReferences(PlotLineImportPackage package)
|
|
{
|
|
var sceneKeys = package.Books
|
|
.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => $"{book.SeriesOrder}/{chapter.Order}/{scene.Order}")))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
var stateReferences = package.StoryAssets
|
|
.SelectMany(asset => asset.States.Select((state, index) => new
|
|
{
|
|
AssetName = DisplayName(asset.DisplayName, asset.Name),
|
|
Label = string.IsNullOrWhiteSpace(state.StateName) ? $"State {index + 1}" : state.StateName.Trim(),
|
|
State = state
|
|
}))
|
|
.Where(x => !HasSceneOrderReference(x.State) || !sceneKeys.Contains(AssetStateSceneKey(x.State)))
|
|
.Select(x => $"{x.AssetName} / state / {x.Label} -> {AssetStateSceneKey(x.State)}. {SceneReferenceDiagnostic(package, AssetStateSceneKey(x.State))}");
|
|
|
|
var eventReferences = package.StoryAssets
|
|
.SelectMany(asset => asset.Events.Select((assetEvent, index) => new
|
|
{
|
|
AssetName = DisplayName(asset.DisplayName, asset.Name),
|
|
Label = string.IsNullOrWhiteSpace(assetEvent.Title) ? $"Event {index + 1}" : assetEvent.Title.Trim(),
|
|
Event = assetEvent
|
|
}))
|
|
.Where(x => !HasSceneOrderReference(x.Event) || !sceneKeys.Contains(AssetEventSceneKey(x.Event)))
|
|
.Select(x => $"{x.AssetName} / event / {x.Label} -> {AssetEventSceneKey(x.Event)}. {SceneReferenceDiagnostic(package, AssetEventSceneKey(x.Event))}");
|
|
|
|
return stateReferences
|
|
.Concat(eventReferences)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(x => x)
|
|
.ToList();
|
|
}
|
|
|
|
private static IReadOnlyList<string> FindUnmatchedAssetOwnerNames(PlotLineImportPackage package)
|
|
{
|
|
var characterLookup = BuildCharacterLookup(package);
|
|
return package.StoryAssets
|
|
.Select(asset => asset.InitialOwnerCharacterName?.Trim())
|
|
.Concat(package.StoryAssets.SelectMany(asset => asset.Events.Select(assetEvent => assetEvent.CharacterName?.Trim())))
|
|
.Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(characterLookup, ResolveAlias(name, package.Aliases?.Characters)))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(name => name)
|
|
.ToList()!;
|
|
}
|
|
|
|
private static IReadOnlyList<string> FindUnmatchedAssetLocationNames(PlotLineImportPackage package)
|
|
{
|
|
var locationLookup = BuildLocationLookup(package);
|
|
return package.StoryAssets
|
|
.Select(asset => asset.InitialLocationName?.Trim())
|
|
.Concat(package.StoryAssets.SelectMany(asset => asset.Events.Select(assetEvent => assetEvent.LocationName?.Trim())))
|
|
.Where(name => !string.IsNullOrWhiteSpace(name) && !IsKnownName(locationLookup, ResolveAlias(name, package.Aliases?.Locations)))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(name => name)
|
|
.ToList()!;
|
|
}
|
|
|
|
private static IReadOnlyList<string> FindUnresolvedSceneDependencyReferences(PlotLineImportPackage package)
|
|
{
|
|
var sceneKeys = package.Books
|
|
.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => $"{book.SeriesOrder}/{chapter.Order}/{scene.Order}")))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
return package.SceneDependencies
|
|
.SelectMany((dependency, index) => new[]
|
|
{
|
|
new
|
|
{
|
|
Label = $"Dependency {index + 1} fromScene",
|
|
Reference = dependency.FromScene
|
|
},
|
|
new
|
|
{
|
|
Label = $"Dependency {index + 1} toScene",
|
|
Reference = dependency.ToScene
|
|
}
|
|
})
|
|
.Where(x => !HasSceneOrderReference(x.Reference) || !sceneKeys.Contains(SceneReferenceKey(x.Reference!)))
|
|
.Select(x => $"{x.Label} -> {SceneReferenceKey(x.Reference)}. {SceneReferenceDiagnostic(package, SceneReferenceKey(x.Reference))}")
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.OrderBy(x => x)
|
|
.ToList();
|
|
}
|
|
|
|
private static bool HasSceneOrderReference(ImportThreadEventDto threadEvent)
|
|
=> threadEvent.BookOrder.HasValue && threadEvent.ChapterOrder.HasValue && threadEvent.SceneOrder.HasValue;
|
|
|
|
private static string EventSceneKey(ImportThreadEventDto threadEvent)
|
|
=> $"{threadEvent.BookOrder?.ToString() ?? "?"}/{threadEvent.ChapterOrder?.ToString() ?? "?"}/{threadEvent.SceneOrder?.ToString() ?? "?"}";
|
|
|
|
private static bool HasSceneOrderReference(ImportAssetStateDto state)
|
|
=> state.BookOrder.HasValue && state.ChapterOrder.HasValue && state.SceneOrder.HasValue;
|
|
|
|
private static bool HasSceneOrderReference(ImportAssetEventDto assetEvent)
|
|
=> assetEvent.BookOrder.HasValue && assetEvent.ChapterOrder.HasValue && assetEvent.SceneOrder.HasValue;
|
|
|
|
private static string AssetStateSceneKey(ImportAssetStateDto state)
|
|
=> $"{state.BookOrder?.ToString() ?? "?"}/{state.ChapterOrder?.ToString() ?? "?"}/{state.SceneOrder?.ToString() ?? "?"}";
|
|
|
|
private static string AssetEventSceneKey(ImportAssetEventDto assetEvent)
|
|
=> $"{assetEvent.BookOrder?.ToString() ?? "?"}/{assetEvent.ChapterOrder?.ToString() ?? "?"}/{assetEvent.SceneOrder?.ToString() ?? "?"}";
|
|
|
|
private static bool HasSceneOrderReference(ImportSceneReferenceDto? reference)
|
|
=> reference?.BookOrder.HasValue == true && reference.ChapterOrder.HasValue && reference.SceneOrder.HasValue;
|
|
|
|
private static string SceneReferenceKey(ImportSceneReferenceDto? reference)
|
|
=> reference is null
|
|
? "(missing)"
|
|
: $"{reference.BookOrder?.ToString() ?? "?"}/{reference.ChapterOrder?.ToString() ?? "?"}/{reference.SceneOrder?.ToString() ?? "?"}";
|
|
|
|
private static string SceneDependencyKey(ImportSceneDependencyDto dependency)
|
|
=> $"{SceneReferenceKey(dependency.FromScene)} -> {SceneReferenceKey(dependency.ToScene)} / {CleanDependencyType(dependency.DependencyType)}";
|
|
|
|
private static string SceneReferenceDiagnostic(PlotLineImportPackage package, string requestedKey)
|
|
{
|
|
var scenes = BuildSceneReferenceInfos(package);
|
|
if (!TryParseSceneKey(requestedKey, out var requestedBook, out var requestedChapter, out var requestedScene))
|
|
{
|
|
return "Likely cause: missing bookOrder, chapterOrder, or sceneOrder.";
|
|
}
|
|
|
|
var closest = scenes
|
|
.OrderBy(scene => Math.Abs(scene.BookOrder - requestedBook) * 10_000 + Math.Abs(scene.ChapterOrder - requestedChapter) * 100 + Math.Abs(scene.SceneOrder - requestedScene))
|
|
.FirstOrDefault();
|
|
|
|
var cause = !scenes.Any(scene => scene.BookOrder == requestedBook)
|
|
? $"Book {requestedBook} was not found."
|
|
: !scenes.Any(scene => scene.BookOrder == requestedBook && scene.ChapterOrder == requestedChapter)
|
|
? $"Book {requestedBook} chapter {requestedChapter} was not found."
|
|
: $"Book {requestedBook} chapter {requestedChapter} scene {requestedScene} was not found.";
|
|
|
|
return closest is null
|
|
? $"Likely cause: {cause}"
|
|
: $"Closest scene: Book {closest.BookOrder} Chapter {closest.ChapterOrder} Scene {closest.SceneOrder} ({closest.SceneTitle}). Likely cause: {cause}";
|
|
}
|
|
|
|
private static IReadOnlyList<ImportSceneReferenceInfo> BuildSceneReferenceInfos(PlotLineImportPackage package)
|
|
=> package.Books
|
|
.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => new ImportSceneReferenceInfo(book.SeriesOrder, chapter.Order, scene.Order, scene.Title))))
|
|
.ToList();
|
|
|
|
private static bool TryParseSceneKey(string key, out int bookOrder, out int chapterOrder, out int sceneOrder)
|
|
{
|
|
bookOrder = 0;
|
|
chapterOrder = 0;
|
|
sceneOrder = 0;
|
|
var parts = key.Split('/');
|
|
return parts.Length == 3
|
|
&& int.TryParse(parts[0], out bookOrder)
|
|
&& int.TryParse(parts[1], out chapterOrder)
|
|
&& int.TryParse(parts[2], out sceneOrder);
|
|
}
|
|
|
|
private static string CleanDependencyType(string? dependencyType)
|
|
=> string.IsNullOrWhiteSpace(dependencyType) ? "Must Occur Before" : dependencyType.Trim();
|
|
|
|
private static bool IsSupportedDependencyStrength(string strength)
|
|
{
|
|
var normalized = strength.Trim();
|
|
return normalized.Equals("hard", StringComparison.OrdinalIgnoreCase)
|
|
|| normalized.Equals("soft", StringComparison.OrdinalIgnoreCase)
|
|
|| normalized.Equals("strong", StringComparison.OrdinalIgnoreCase)
|
|
|| normalized.Equals("weak", StringComparison.OrdinalIgnoreCase)
|
|
|| normalized.Equals("required", StringComparison.OrdinalIgnoreCase)
|
|
|| normalized.Equals("optional", StringComparison.OrdinalIgnoreCase)
|
|
|| normalized.Equals("high", StringComparison.OrdinalIgnoreCase)
|
|
|| normalized.Equals("medium", StringComparison.OrdinalIgnoreCase)
|
|
|| normalized.Equals("low", StringComparison.OrdinalIgnoreCase)
|
|
|| (int.TryParse(normalized, out var numeric) && numeric is >= 1 and <= 10);
|
|
}
|
|
|
|
private static IReadOnlyList<string> FindAliasWarnings(PlotLineImportPackage package)
|
|
{
|
|
var warnings = new List<string>();
|
|
var characterNames = BuildNameLookup(package.Characters.SelectMany(character => new[] { character.Name, character.DisplayName }));
|
|
var locationNames = BuildNameLookup(package.Locations.SelectMany(location => new[] { location.Name, location.DisplayName }));
|
|
|
|
foreach (var alias in package.Aliases?.Characters ?? [])
|
|
{
|
|
if (!ContainsLookupKey(characterNames, alias.Value))
|
|
{
|
|
warnings.Add($"Character alias \"{alias.Key}\" points to \"{alias.Value}\", but that target is not a top-level character.");
|
|
}
|
|
}
|
|
|
|
foreach (var alias in package.Aliases?.Locations ?? [])
|
|
{
|
|
if (!ContainsLookupKey(locationNames, alias.Value))
|
|
{
|
|
warnings.Add($"Location alias \"{alias.Key}\" points to \"{alias.Value}\", but that target is not a top-level location.");
|
|
}
|
|
}
|
|
|
|
return warnings;
|
|
}
|
|
|
|
private static IReadOnlyList<string> FindAutoCorrections(PlotLineImportPackage package)
|
|
{
|
|
var corrections = new List<string>();
|
|
AddAliasCorrections(corrections, "Character alias", package.Aliases?.Characters);
|
|
AddAliasCorrections(corrections, "Location alias", package.Aliases?.Locations);
|
|
|
|
foreach (var value in AllImportedNames(package).Where(value => !string.IsNullOrWhiteSpace(value)))
|
|
{
|
|
var collapsed = CollapseSpaces(value!);
|
|
if (!string.Equals(value!.Trim(), collapsed, StringComparison.Ordinal))
|
|
{
|
|
corrections.Add($"Repeated spaces normalized: \"{value}\" -> \"{collapsed}\".");
|
|
}
|
|
}
|
|
|
|
return corrections.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList();
|
|
}
|
|
|
|
private static IReadOnlyList<string> FindSkippedItems(PlotLineImportPackage package)
|
|
{
|
|
var skipped = new List<string>();
|
|
|
|
foreach (var duplicate in package.Books
|
|
.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.SelectMany(scene => scene.SceneCharacters.Select(sceneCharacter => new
|
|
{
|
|
Book = book.Title,
|
|
Chapter = chapter.Title,
|
|
Scene = scene.Title,
|
|
sceneCharacter.CharacterName
|
|
}))))
|
|
.Where(x => !string.IsNullOrWhiteSpace(x.CharacterName))
|
|
.GroupBy(x => $"{NormalizeNameKey(x.Book)}/{NormalizeNameKey(x.Chapter)}/{NormalizeNameKey(x.Scene)}/{NormalizeNameKey(x.CharacterName)}", StringComparer.OrdinalIgnoreCase)
|
|
.Where(group => group.Count() > 1))
|
|
{
|
|
var item = duplicate.First();
|
|
skipped.Add($"Duplicate scene appearance skipped: {item.Book} / {item.Chapter} / {item.Scene} / {item.CharacterName.Trim()}");
|
|
}
|
|
|
|
foreach (var duplicate in package.SceneDependencies
|
|
.Where(dependency => HasSceneOrderReference(dependency.FromScene) && HasSceneOrderReference(dependency.ToScene))
|
|
.GroupBy(SceneDependencyKey, StringComparer.OrdinalIgnoreCase)
|
|
.Where(group => group.Count() > 1))
|
|
{
|
|
skipped.Add($"Duplicate scene dependency skipped: {duplicate.Key}");
|
|
}
|
|
|
|
return skipped.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList();
|
|
}
|
|
|
|
private static void AddAliasCorrections(List<string> corrections, string label, Dictionary<string, string>? aliases)
|
|
{
|
|
foreach (var alias in aliases ?? [])
|
|
{
|
|
corrections.Add($"{label} applied: \"{alias.Key}\" -> \"{alias.Value}\".");
|
|
}
|
|
}
|
|
|
|
private static Dictionary<string, bool> BuildCharacterLookup(PlotLineImportPackage package)
|
|
=> BuildNameLookup(package.Characters.SelectMany(character => new[] { character.Name, character.DisplayName }));
|
|
|
|
private static Dictionary<string, bool> BuildLocationLookup(PlotLineImportPackage package)
|
|
=> BuildNameLookup(package.Locations.SelectMany(location => new[] { location.Name, location.DisplayName }));
|
|
|
|
private static Dictionary<string, bool> BuildNameLookup(IEnumerable<string?> names)
|
|
{
|
|
var lookup = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
|
var candidateOwners = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var name in names.Where(name => !string.IsNullOrWhiteSpace(name)))
|
|
{
|
|
foreach (var key in CandidateNameKeys(name))
|
|
{
|
|
if (candidateOwners.TryGetValue(key, out var existing) && !string.Equals(existing, name, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
lookup[key] = false;
|
|
}
|
|
else
|
|
{
|
|
candidateOwners[key] = name!;
|
|
lookup[key] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return lookup;
|
|
}
|
|
|
|
private static bool ContainsLookupKey(Dictionary<string, bool> lookup, string? name)
|
|
=> IsKnownName(lookup, name);
|
|
|
|
private static bool IsKnownName(Dictionary<string, bool> lookup, string? name)
|
|
=> CandidateNameKeys(name).Any(key => lookup.TryGetValue(key, out var isSafe) && isSafe);
|
|
|
|
private static string ResolveAlias(string? name, Dictionary<string, string>? aliases)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
var clean = CollapseSpaces(name);
|
|
if (aliases is null || aliases.Count == 0)
|
|
{
|
|
return clean;
|
|
}
|
|
|
|
foreach (var key in CandidateNameKeys(clean))
|
|
{
|
|
var match = aliases.FirstOrDefault(alias => CandidateNameKeys(alias.Key).Contains(key, StringComparer.OrdinalIgnoreCase));
|
|
if (!string.IsNullOrWhiteSpace(match.Value))
|
|
{
|
|
return CollapseSpaces(match.Value);
|
|
}
|
|
}
|
|
|
|
return clean;
|
|
}
|
|
|
|
private static IEnumerable<string?> AllImportedNames(PlotLineImportPackage package)
|
|
=> package.Characters.SelectMany(character => new[] { character.Name, character.DisplayName, character.ShortName })
|
|
.Concat(package.Locations.SelectMany(location => new[] { location.Name, location.DisplayName, location.ParentLocationName }))
|
|
.Concat(package.Books.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.SelectMany(scene => new[] { scene.PovCharacterName, scene.LocationName }))))
|
|
.Concat(package.Books.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.SelectMany(scene => scene.SceneCharacters.Select(sceneCharacter => sceneCharacter.CharacterName)))))
|
|
.Concat(package.StoryAssets.SelectMany(asset => new[] { asset.InitialOwnerCharacterName, asset.InitialLocationName }));
|
|
|
|
private static IEnumerable<string> CandidateNameKeys(string? name)
|
|
{
|
|
var normalized = NormalizeNameKey(name);
|
|
if (normalized.Length == 0)
|
|
{
|
|
yield break;
|
|
}
|
|
|
|
yield return normalized;
|
|
|
|
var punctuationInsensitive = new string(normalized.Where(ch => !char.IsPunctuation(ch)).ToArray());
|
|
punctuationInsensitive = CollapseSpaces(punctuationInsensitive);
|
|
if (punctuationInsensitive.Length > 0 && punctuationInsensitive != normalized)
|
|
{
|
|
yield return punctuationInsensitive;
|
|
}
|
|
}
|
|
|
|
private static string NormalizeNameKey(string? value)
|
|
=> CollapseSpaces(value).ToUpperInvariant();
|
|
|
|
private static string CollapseSpaces(string? value)
|
|
{
|
|
var clean = (value ?? string.Empty).Trim();
|
|
if (clean.Length == 0)
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
var builder = new StringBuilder(clean.Length);
|
|
var lastWasSpace = false;
|
|
foreach (var ch in clean)
|
|
{
|
|
if (char.IsWhiteSpace(ch))
|
|
{
|
|
if (!lastWasSpace)
|
|
{
|
|
builder.Append(' ');
|
|
lastWasSpace = true;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
builder.Append(ch);
|
|
lastWasSpace = false;
|
|
}
|
|
}
|
|
|
|
return builder.ToString();
|
|
}
|
|
|
|
private static void ValidateLargeText(ImportValidationResult result, string fieldName, string? value)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(value) && value.Length > LargeTextWarningLength)
|
|
{
|
|
result.Warnings.Add($"{fieldName} is unusually large. It can be imported, but review the source JSON for accidental pasted manuscript text.");
|
|
}
|
|
}
|
|
|
|
private static string FormatBookTitle(ImportBookDto book)
|
|
=> string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title.Trim() : $"{book.Title.Trim()}: {book.Subtitle.Trim()}";
|
|
|
|
private static string DisplayName(string? displayName, string fallback)
|
|
=> string.IsNullOrWhiteSpace(displayName) ? fallback.Trim() : displayName.Trim();
|
|
|
|
private static void AppendReportSection(StringBuilder builder, string title, IReadOnlyList<string> messages)
|
|
{
|
|
builder.AppendLine(title);
|
|
if (messages.Count == 0)
|
|
{
|
|
builder.AppendLine("- None");
|
|
}
|
|
else
|
|
{
|
|
foreach (var message in messages)
|
|
{
|
|
builder.AppendLine($"- {message}");
|
|
}
|
|
}
|
|
|
|
builder.AppendLine();
|
|
}
|
|
|
|
private static async Task<string> ReadJsonTextAsync(ImportIndexViewModel model)
|
|
{
|
|
if (model.JsonFile is { Length: > 0 }
|
|
&& !string.IsNullOrWhiteSpace(model.JsonText)
|
|
&& !string.Equals(model.JsonText.Trim(), ImportIndexViewModel.SampleJson.Trim(), StringComparison.Ordinal))
|
|
{
|
|
return model.JsonText;
|
|
}
|
|
|
|
if (model.JsonFile is { Length: > 0 })
|
|
{
|
|
using var reader = new StreamReader(model.JsonFile.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
|
return await reader.ReadToEndAsync();
|
|
}
|
|
|
|
return model.JsonText ?? string.Empty;
|
|
}
|
|
|
|
private sealed record ImportSceneReferenceInfo(int BookOrder, int ChapterOrder, int SceneOrder, string SceneTitle);
|
|
}
|