diff --git a/.appdata/SqlRunner/Program.cs b/.appdata/SqlRunner/Program.cs index a4e44ad..3e589a0 100644 --- a/.appdata/SqlRunner/Program.cs +++ b/.appdata/SqlRunner/Program.cs @@ -29,7 +29,26 @@ foreach (var batch in batches) await using var command = new SqlCommand(batch, connection); command.CommandTimeout = 120; - await command.ExecuteNonQueryAsync(); + await using var reader = await command.ExecuteReaderAsync(); + var resultSet = 0; + do + { + if (reader.FieldCount > 0) + { + resultSet++; + Console.WriteLine($"-- Result set {count + 1}.{resultSet}"); + var headers = Enumerable.Range(0, reader.FieldCount).Select(reader.GetName); + Console.WriteLine(string.Join("\t", headers)); + while (await reader.ReadAsync()) + { + var values = Enumerable.Range(0, reader.FieldCount) + .Select(i => reader.IsDBNull(i) ? "NULL" : Convert.ToString(reader.GetValue(i))?.Replace("\r", "\\r").Replace("\n", "\\n")); + Console.WriteLine(string.Join("\t", values)); + } + } + } + while (await reader.NextResultAsync()); + count++; } diff --git a/PlotLine/Controllers/ProjectRestorePointsController.cs b/PlotLine/Controllers/ProjectRestorePointsController.cs index 8f3ac44..fbff42d 100644 --- a/PlotLine/Controllers/ProjectRestorePointsController.cs +++ b/PlotLine/Controllers/ProjectRestorePointsController.cs @@ -20,6 +20,12 @@ public sealed class ProjectRestorePointsController( { try { + if (await restorePoints.HasReachedManualLimitAsync(projectId)) + { + TempData["ProjectRestorePointMessage"] = ProjectRestorePointService.LimitReachedMessage; + return RedirectToAction(nameof(Index), new { projectId }); + } + var created = await restorePoints.CreateManualAsync(projectId, restorePointName, notes); TempData["ProjectRestorePointMessage"] = created is null ? "Restore point could not be created because the project was not found." @@ -72,4 +78,24 @@ public sealed class ProjectRestorePointsController( return RedirectToAction(nameof(Index), new { projectId }); } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Delete(int projectId, int id) + { + try + { + var deleted = await restorePoints.DeleteAsync(id, projectId); + TempData["ProjectRestorePointMessage"] = deleted + ? "Restore point deleted." + : "Restore point could not be deleted. It may not exist."; + } + catch (Exception ex) + { + logger.LogError(ex, "Restore point delete failed unexpectedly for restore point {ProjectRestorePointID} in project {ProjectID}.", id, projectId); + TempData["ProjectRestorePointMessage"] = "Restore point could not be deleted. Please try again, or check the application logs if the problem continues."; + } + + return RedirectToAction(nameof(Index), new { projectId }); + } } diff --git a/PlotLine/Data/ImportRepository.cs b/PlotLine/Data/ImportRepository.cs index b028882..468578f 100644 --- a/PlotLine/Data/ImportRepository.cs +++ b/PlotLine/Data/ImportRepository.cs @@ -9,6 +9,11 @@ public interface IImportRepository { Task ProjectTitleExistsAsync(string projectTitle); Task ImportAsync(PlotLineImportPackage package, string? importedProjectTitle); + Task> GetIdentityMaxValuesAsync(IEnumerable tableNames); + Task GetRelationshipImportLookupsAsync(); + Task GetRelationshipImportDiagnosticsAsync(int projectId); + Task CreateProjectShellAsync(string projectName, string? description); + Task ApplyProjectBackupJsonAsync(int projectId, string jsonData); } public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : IImportRepository @@ -29,6 +34,90 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : return count > 0; } + public async Task> GetIdentityMaxValuesAsync(IEnumerable tableNames) + { + using var connection = connectionFactory.CreateConnection(); + var requestedTables = tableNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var rows = await connection.QueryAsync( + "dbo.ProjectBackup_GetIdentityMaxValues", + new { TableNames = string.Join(',', requestedTables) }, + commandType: CommandType.StoredProcedure); + + return rows.ToDictionary(row => row.TableName, row => row.MaxID, StringComparer.OrdinalIgnoreCase); + } + + public async Task GetRelationshipImportLookupsAsync() + { + using var connection = connectionFactory.CreateConnection(); + using var result = await connection.QueryMultipleAsync( + "dbo.ProjectBackup_GetRelationshipImportLookups", + commandType: CommandType.StoredProcedure); + + return new ProjectBackupRelationshipImportLookups + { + RevisionStatuses = (await result.ReadAsync()).ToList(), + TimeModes = (await result.ReadAsync()).ToList(), + DurationUnits = (await result.ReadAsync()).ToList(), + TimeConfidences = (await result.ReadAsync()).ToList(), + LocationTypes = (await result.ReadAsync()).ToList(), + LocationRelationshipTypes = (await result.ReadAsync()).ToList(), + PlotLineTypes = (await result.ReadAsync()).ToList(), + ThreadTypes = (await result.ReadAsync()).ToList(), + ThreadStatuses = (await result.ReadAsync()).ToList(), + ThreadEventTypes = (await result.ReadAsync()).ToList(), + AssetKinds = (await result.ReadAsync()).ToList(), + AssetStates = (await result.ReadAsync()).ToList(), + AssetEventTypes = (await result.ReadAsync()).ToList(), + AssetDependencyTypes = (await result.ReadAsync()).ToList(), + AssetCustodyEventTypes = (await result.ReadAsync()).ToList(), + CustodyRoles = (await result.ReadAsync()).ToList(), + CharacterRoleTypes = (await result.ReadAsync()).ToList(), + PresenceTypes = (await result.ReadAsync()).ToList(), + KnowledgeStates = (await result.ReadAsync()).ToList(), + SceneDependencyTypes = (await result.ReadAsync()).ToList(), + SceneNoteTypes = (await result.ReadAsync()).ToList(), + SceneAttachmentTypes = (await result.ReadAsync()).ToList(), + ScenePurposeTypes = (await result.ReadAsync()).ToList(), + SceneMetricTypes = (await result.ReadAsync()).ToList(), + RelationshipTypes = (await result.ReadAsync()).ToList(), + RelationshipStates = (await result.ReadAsync()).ToList() + }; + } + + public async Task GetRelationshipImportDiagnosticsAsync(int projectId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.ProjectBackup_RelationshipImportDiagnostics", + new { ProjectID = projectId }, + commandType: CommandType.StoredProcedure); + } + + public async Task CreateProjectShellAsync(string projectName, string? description) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.Project_Save", + new { ProjectID = 0, ProjectName = projectName, Description = description }, + commandType: CommandType.StoredProcedure); + } + + public async Task ApplyProjectBackupJsonAsync(int projectId, string jsonData) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.ProjectRestore_ApplyFromJson", + new + { + ProjectID = projectId, + SourceRestorePointID = (int?)null, + SafetyRestorePointID = (int?)null, + JsonData = jsonData + }, + commandType: CommandType.StoredProcedure, + commandTimeout: 180); + } + public async Task ImportAsync(PlotLineImportPackage package, string? importedProjectTitle) { using var connection = connectionFactory.CreateConnection(); @@ -1322,4 +1411,10 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : public int LookupID { get; set; } public string StatusName { get; set; } = string.Empty; } + + private sealed class IdentityMaxRow + { + public string TableName { get; set; } = string.Empty; + public int MaxID { get; set; } + } } diff --git a/PlotLine/Data/ProjectBackupRepository.cs b/PlotLine/Data/ProjectBackupRepository.cs index 7c894cc..5dc7bc6 100644 --- a/PlotLine/Data/ProjectBackupRepository.cs +++ b/PlotLine/Data/ProjectBackupRepository.cs @@ -62,7 +62,9 @@ public sealed class ProjectBackupRepository(ISqlConnectionFactory connectionFact Scenarios = (await result.ReadAsync()).ToList(), ScenarioSceneOrder = (await result.ReadAsync()).ToList(), ScenarioValidationRuns = (await result.ReadAsync()).ToList(), - ScenarioWarnings = (await result.ReadAsync()).ToList() + ScenarioWarnings = (await result.ReadAsync()).ToList(), + GenreMetricPresets = (await result.ReadAsync()).ToList(), + GenreMetricPresetMetrics = (await result.ReadAsync()).ToList() }; if (project is null) @@ -120,7 +122,9 @@ public sealed class ProjectBackupRepository(ISqlConnectionFactory connectionFact nameof(data.Scenarios), nameof(data.ScenarioSceneOrder), nameof(data.ScenarioValidationRuns), - nameof(data.ScenarioWarnings) + nameof(data.ScenarioWarnings), + nameof(data.GenreMetricPresets), + nameof(data.GenreMetricPresetMetrics) ], Note = "Original database IDs are included for diagnostics. Future import/restore should map by logical project structure and names where practical." } diff --git a/PlotLine/Data/ProjectRestorePointRepository.cs b/PlotLine/Data/ProjectRestorePointRepository.cs index 1eddfbe..10951f3 100644 --- a/PlotLine/Data/ProjectRestorePointRepository.cs +++ b/PlotLine/Data/ProjectRestorePointRepository.cs @@ -8,7 +8,9 @@ public interface IProjectRestorePointRepository { Task> ListAsync(int projectId); Task GetAsync(int projectRestorePointId, int projectId); + Task CountAsync(int projectId); Task CreateAsync(int projectId, string restorePointName, string restorePointType, string jsonData, string? notes); + Task DeleteAsync(int projectRestorePointId, int projectId); Task RestoreAsync(int projectId, int sourceRestorePointId, int safetyRestorePointId, string jsonData); } @@ -33,6 +35,15 @@ public sealed class ProjectRestorePointRepository(ISqlConnectionFactory connecti commandType: CommandType.StoredProcedure); } + public async Task CountAsync(int projectId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.ProjectRestorePoint_Count", + new { ProjectID = projectId }, + commandType: CommandType.StoredProcedure); + } + public async Task CreateAsync(int projectId, string restorePointName, string restorePointType, string jsonData, string? notes) { using var connection = connectionFactory.CreateConnection(); @@ -49,6 +60,15 @@ public sealed class ProjectRestorePointRepository(ISqlConnectionFactory connecti commandType: CommandType.StoredProcedure); } + public async Task DeleteAsync(int projectRestorePointId, int projectId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.ProjectRestorePoint_Delete", + new { ProjectRestorePointID = projectRestorePointId, ProjectID = projectId }, + commandType: CommandType.StoredProcedure); + } + public async Task RestoreAsync(int projectId, int sourceRestorePointId, int safetyRestorePointId, string jsonData) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 15c77c5..95cf86b 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -8,6 +8,8 @@ public sealed class Project public DateTime CreatedDate { get; set; } public DateTime UpdatedDate { get; set; } public bool IsArchived { get; set; } + public DateTime? ArchivedDate { get; set; } + public string? ArchivedReason { get; set; } } public sealed class GenreMetricPreset @@ -31,6 +33,8 @@ public sealed class Book public DateTime CreatedDate { get; set; } public DateTime UpdatedDate { get; set; } public bool IsArchived { get; set; } + public DateTime? ArchivedDate { get; set; } + public string? ArchivedReason { get; set; } } public sealed class Chapter @@ -47,6 +51,8 @@ public sealed class Chapter public DateTime CreatedDate { get; set; } public DateTime UpdatedDate { get; set; } public bool IsArchived { get; set; } + public DateTime? ArchivedDate { get; set; } + public string? ArchivedReason { get; set; } } public sealed class Scene @@ -78,6 +84,8 @@ public sealed class Scene public DateTime CreatedDate { get; set; } public DateTime UpdatedDate { get; set; } public bool IsArchived { get; set; } + public DateTime? ArchivedDate { get; set; } + public string? ArchivedReason { get; set; } public List SelectedPurposeTypeIds { get; set; } = []; public List PurposeLabels { get; set; } = []; public List Metrics { get; set; } = []; @@ -153,6 +161,9 @@ public sealed class SceneMetricType public int SortOrder { get; set; } public bool IsActive { get; set; } public bool IsEnabledForProject { get; set; } + public bool IsCustom { get; set; } + public string? GenrePresetKeys { get; set; } + public string? GenrePresetNames { get; set; } public int ValueCount { get; set; } } @@ -215,6 +226,7 @@ public sealed class ProjectTimelineMetricSetting public int MetricTypeID { get; set; } public string MetricName { get; set; } = string.Empty; public int SortOrder { get; set; } + public DateTime CreatedDate { get; set; } } public sealed class PlotLineType @@ -244,6 +256,8 @@ public sealed class PlotLineItem public DateTime CreatedDate { get; set; } public DateTime UpdatedDate { get; set; } public bool IsArchived { get; set; } + public DateTime? ArchivedDate { get; set; } + public string? ArchivedReason { get; set; } } public sealed class ThreadType @@ -283,6 +297,8 @@ public sealed class PlotThread public DateTime CreatedDate { get; set; } public DateTime UpdatedDate { get; set; } public bool IsArchived { get; set; } + public DateTime? ArchivedDate { get; set; } + public string? ArchivedReason { get; set; } } public sealed class ThreadEventType @@ -343,6 +359,7 @@ public sealed class AssetState public int AssetStateID { get; set; } public int? ProjectID { get; set; } public int? AssetKindID { get; set; } + public string? KindName { get; set; } public string StateName { get; set; } = string.Empty; public int SortOrder { get; set; } public string? Description { get; set; } @@ -369,6 +386,8 @@ public sealed class StoryAsset public DateTime CreatedDate { get; set; } public DateTime UpdatedDate { get; set; } public bool IsArchived { get; set; } + public DateTime? ArchivedDate { get; set; } + public string? ArchivedReason { get; set; } } public sealed class AssetEventType @@ -433,6 +452,8 @@ public sealed class AssetDependency public DateTime CreatedDate { get; set; } public DateTime UpdatedDate { get; set; } public bool IsArchived { get; set; } + public DateTime? ArchivedDate { get; set; } + public string? ArchivedReason { get; set; } } public sealed class AssetCustodyEventType @@ -508,6 +529,8 @@ public sealed class Character public DateTime CreatedDate { get; set; } public DateTime UpdatedDate { get; set; } public bool IsArchived { get; set; } + public DateTime? ArchivedDate { get; set; } + public string? ArchivedReason { get; set; } } public sealed class CharacterRoleInSceneType @@ -677,6 +700,8 @@ public sealed class CharacterRelationship public DateTime CreatedDate { get; set; } public DateTime UpdatedDate { get; set; } public bool IsArchived { get; set; } + public DateTime? ArchivedDate { get; set; } + public string? ArchivedReason { get; set; } } public sealed class RelationshipEvent @@ -762,6 +787,8 @@ public sealed class LocationItem public DateTime CreatedDate { get; set; } public DateTime UpdatedDate { get; set; } public bool IsArchived { get; set; } + public DateTime? ArchivedDate { get; set; } + public string? ArchivedReason { get; set; } } public sealed class LocationRelationship diff --git a/PlotLine/Models/ImportModels.cs b/PlotLine/Models/ImportModels.cs index 9a885bb..9c001d2 100644 --- a/PlotLine/Models/ImportModels.cs +++ b/PlotLine/Models/ImportModels.cs @@ -29,6 +29,59 @@ public sealed class ImportAliasesDto public Dictionary Locations { get; set; } = new(StringComparer.OrdinalIgnoreCase); } +public sealed class ProjectBackupRelationshipImportLookups +{ + public IReadOnlyList RevisionStatuses { get; init; } = []; + public IReadOnlyList TimeModes { get; init; } = []; + public IReadOnlyList DurationUnits { get; init; } = []; + public IReadOnlyList TimeConfidences { get; init; } = []; + public IReadOnlyList LocationTypes { get; init; } = []; + public IReadOnlyList LocationRelationshipTypes { get; init; } = []; + public IReadOnlyList PlotLineTypes { get; init; } = []; + public IReadOnlyList ThreadTypes { get; init; } = []; + public IReadOnlyList ThreadStatuses { get; init; } = []; + public IReadOnlyList ThreadEventTypes { get; init; } = []; + public IReadOnlyList AssetKinds { get; init; } = []; + public IReadOnlyList AssetStates { get; init; } = []; + public IReadOnlyList AssetEventTypes { get; init; } = []; + public IReadOnlyList AssetDependencyTypes { get; init; } = []; + public IReadOnlyList AssetCustodyEventTypes { get; init; } = []; + public IReadOnlyList CustodyRoles { get; init; } = []; + public IReadOnlyList CharacterRoleTypes { get; init; } = []; + public IReadOnlyList PresenceTypes { get; init; } = []; + public IReadOnlyList KnowledgeStates { get; init; } = []; + public IReadOnlyList SceneDependencyTypes { get; init; } = []; + public IReadOnlyList SceneNoteTypes { get; init; } = []; + public IReadOnlyList SceneAttachmentTypes { get; init; } = []; + public IReadOnlyList ScenePurposeTypes { get; init; } = []; + public IReadOnlyList SceneMetricTypes { get; init; } = []; + public IReadOnlyList RelationshipTypes { get; init; } = []; + public IReadOnlyList RelationshipStates { get; init; } = []; +} + +public sealed class ProjectBackupLookupRow +{ + public int LookupID { get; set; } + public string LookupName { get; set; } = string.Empty; +} + +public sealed class ProjectBackupRelationshipImportDiagnostics +{ + public int CharacterCount { get; set; } + public int RelationshipCount { get; set; } + public int RelationshipEventCount { get; set; } + public int MapRelationshipCount { get; set; } + public int MissingSourceCharacterCount { get; set; } + public int MissingTargetCharacterCount { get; set; } + public int MissingRelationshipTypeCount { get; set; } + public int MissingInitialStateCount { get; set; } + public int MissingEventRelationshipCount { get; set; } + public int MissingEventSceneCount { get; set; } + public int MissingEventStateCount { get; set; } + public int ArchivedRelationshipCount { get; set; } + public int ArchivedCharacterEndpointCount { get; set; } +} + public sealed class ImportProjectDto { public string Title { get; set; } = string.Empty; diff --git a/PlotLine/Models/ProjectBackupModels.cs b/PlotLine/Models/ProjectBackupModels.cs index 86bdae8..7694282 100644 --- a/PlotLine/Models/ProjectBackupModels.cs +++ b/PlotLine/Models/ProjectBackupModels.cs @@ -54,6 +54,8 @@ public sealed class ProjectBackupData public IReadOnlyList ScenarioSceneOrder { get; init; } = []; public IReadOnlyList ScenarioValidationRuns { get; init; } = []; public IReadOnlyList ScenarioWarnings { get; init; } = []; + public IReadOnlyList GenreMetricPresets { get; init; } = []; + public IReadOnlyList GenreMetricPresetMetrics { get; init; } = []; } public sealed class ProjectBackupDiagnostics @@ -130,3 +132,13 @@ public sealed class ProjectBackupScenarioValidationRun public DateTime? CompletedDate { get; set; } public int WarningCount { get; set; } } + +public sealed class ProjectBackupGenreMetricPresetMetric +{ + public int GenreMetricPresetID { get; set; } + public string PresetKey { get; set; } = string.Empty; + public string PresetName { get; set; } = string.Empty; + public int MetricTypeID { get; set; } + public string MetricName { get; set; } = string.Empty; + public int SortOrder { get; set; } +} diff --git a/PlotLine/Services/ImportServices.cs b/PlotLine/Services/ImportServices.cs index c4ba459..ccfb522 100644 --- a/PlotLine/Services/ImportServices.cs +++ b/PlotLine/Services/ImportServices.cs @@ -23,6 +23,13 @@ public sealed class ImportService(IImportRepository imports, ILogger PreviewAsync(ImportIndexViewModel model) { var timer = Stopwatch.StartNew(); @@ -47,6 +59,25 @@ public sealed class ImportService(IImportRepository imports, ILogger ImportAsync(ImportIndexViewModel model) { 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); + } + + 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; @@ -189,6 +220,23 @@ public sealed class ImportService(IImportRepository imports, ILogger BuildProjectBackupPreviewAsync(ProjectBackupExport backup, ImportValidationResult validation) + { + var package = BuildPreviewPackageFromBackup(backup); + var projectTitle = package.Project.Title; + var duplicate = !string.IsNullOrWhiteSpace(projectTitle) + && await imports.ProjectTitleExistsAsync(projectTitle.Trim()); + var duplicateTitle = duplicate + ? $"{projectTitle.Trim()} (Imported {DateTime.Now:yyyy-MM-dd HH:mm})" + : null; + + validation.Warnings.Add("This is a PlotWeaver 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 ImportProjectBackupAsync(ImportIndexViewModel model, ProjectBackupExport backup, ImportValidationResult validation) + { + 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); + var maxIds = await imports.GetIdentityMaxValuesAsync(ProjectBackupIdentityTables); + var relationshipLookups = await imports.GetRelationshipImportLookupsAsync(); + var remapWarnings = new List(); + 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, "PlotWeaver.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(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, "PlotWeaver.ProjectBackup", StringComparison.Ordinal)) + { + validation.Errors.Add("The project backup format is not recognised."); + } + + if (!string.Equals(backup.FormatVersion, "1.0", StringComparison.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, "PlotWeaver.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 PlotWeaver 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 PlotWeaver 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", "SceneAssetLocations", "SceneDependencies", + "ValidationRuns", "ContinuityWarnings", "SceneNotes", "SceneWorkflow", "SceneChecklistItems", + "SceneAttachments", "ChapterGoals", "Scenarios", "ScenarioSceneOrder", "ScenarioValidationRuns", + "ScenarioWarnings" + ]; + + private static void RemapProjectBackupForImport( + ProjectBackupExport backup, + int projectId, + string projectName, + IReadOnlyDictionary maxIds, + ProjectBackupRelationshipImportLookups relationshipLookups, + ICollection warnings) + { + if (backup.Project is null) + { + return; + } + + var maps = new Dictionary>(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 Map(string table) => maps.TryGetValue(table, out var map) ? map : maps[table] = new Dictionary(); + 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 LookupByName(IEnumerable 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 threadTypeIds = LookupByName(relationshipLookups.ThreadTypes); + var threadStatusIds = LookupByName(relationshipLookups.ThreadStatuses); + var threadEventTypeIds = LookupByName(relationshipLookups.ThreadEventTypes); + 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 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 lookup, int? currentId, string? lookupName, string context) + { + if (!currentId.HasValue) + { + return null; + } + + return ResolveLookupID(lookup, currentId.Value, lookupName, context); + } + int? ResolveMappedOrLookupID(string table, Dictionary 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 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.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"); + } + + foreach (var plotLine in backup.Data.PlotLines) + { + plotLine.ParentPlotLineID = ExistingOrNewNullable("PlotLines", plotLine.ParentPlotLineID); + } + + 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"); + } + + 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 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 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", + _ => null + }; + + return table is null ? entityId : remap(table, entityId); + } + private static ImportResult BuildDryRunResult(ImportPreview preview) { var unresolvedThreadEvents = preview.UnresolvedThreadEventReferences.Count; diff --git a/PlotLine/Services/ProjectRestorePointService.cs b/PlotLine/Services/ProjectRestorePointService.cs index 0c68a4d..449e06f 100644 --- a/PlotLine/Services/ProjectRestorePointService.cs +++ b/PlotLine/Services/ProjectRestorePointService.cs @@ -10,6 +10,8 @@ public interface IProjectRestorePointService Task CreateManualAsync(int projectId, string? restorePointName, string? notes); Task CreatePreRestoreAsync(int projectId, string sourceRestorePointName); Task DownloadAsync(int projectRestorePointId, int projectId); + Task HasReachedManualLimitAsync(int projectId); + Task DeleteAsync(int projectRestorePointId, int projectId); } public sealed class ProjectRestorePointService( @@ -20,6 +22,8 @@ public sealed class ProjectRestorePointService( { private const string ManualRestorePointType = "Manual"; private const string PreRestorePointType = "PreRestore"; + private const int MaxRestorePointsPerProject = 10; + public const string LimitReachedMessage = "You have reached the limit of 10 restore points for this project. Delete an old restore point before creating a new one. You can download a restore point first if you want to keep a local backup copy."; public async Task GetAsync(int projectId) { @@ -38,6 +42,14 @@ public sealed class ProjectRestorePointService( public async Task CreateManualAsync(int projectId, string? restorePointName, string? notes) { + if (await HasReachedManualLimitAsync(projectId)) + { + logger.LogInformation("Manual restore point creation blocked for project {ProjectID} because the project already has {MaxRestorePointCount} restore points.", + projectId, + MaxRestorePointsPerProject); + return null; + } + var name = string.IsNullOrWhiteSpace(restorePointName) ? $"Manual restore point - {DateTime.Now:yyyy-MM-dd HH:mm}" : restorePointName.Trim(); @@ -88,6 +100,20 @@ public sealed class ProjectRestorePointService( }; } + public async Task HasReachedManualLimitAsync(int projectId) + => await restorePoints.CountAsync(projectId) >= MaxRestorePointsPerProject; + + public async Task DeleteAsync(int projectRestorePointId, int projectId) + { + var deleted = await restorePoints.DeleteAsync(projectRestorePointId, projectId); + if (!deleted) + { + logger.LogWarning("Restore point delete failed for restore point {ProjectRestorePointID} in project {ProjectID}.", projectRestorePointId, projectId); + } + + return deleted; + } + private static string? Clean(string? value) { return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); diff --git a/PlotLine/Sql/034_ProjectBackupExport.sql b/PlotLine/Sql/034_ProjectBackupExport.sql index f1d5c89..c3aadb4 100644 --- a/PlotLine/Sql/034_ProjectBackupExport.sql +++ b/PlotLine/Sql/034_ProjectBackupExport.sql @@ -9,17 +9,19 @@ AS BEGIN SET NOCOUNT ON; - SELECT ProjectID, ProjectName, Description, CreatedDate, UpdatedDate, IsArchived + SELECT ProjectID, ProjectName, Description, CreatedDate, UpdatedDate, IsArchived, ArchivedDate, ArchivedReason FROM dbo.Projects WHERE ProjectID = @ProjectID; - SELECT BookID, ProjectID, BookTitle, BookNumber, Description, SortOrder, CreatedDate, UpdatedDate, IsArchived + SELECT BookID, ProjectID, BookTitle, BookNumber, Description, SortOrder, CreatedDate, UpdatedDate, + IsArchived, ArchivedDate, ArchivedReason FROM dbo.Books WHERE ProjectID = @ProjectID ORDER BY SortOrder, BookNumber, BookID; SELECT c.ChapterID, c.BookID, c.ChapterNumber, c.ChapterTitle, c.POVCharacterID, c.Summary, c.SortOrder, - c.RevisionStatusID, rs.StatusName AS RevisionStatusName, c.CreatedDate, c.UpdatedDate, c.IsArchived + c.RevisionStatusID, rs.StatusName AS RevisionStatusName, c.CreatedDate, c.UpdatedDate, + c.IsArchived, c.ArchivedDate, c.ArchivedReason FROM dbo.Chapters c INNER JOIN dbo.Books b ON b.BookID = c.BookID INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = c.RevisionStatusID @@ -30,7 +32,8 @@ BEGIN l.LocationName AS PrimaryLocationName, l.LocationName AS PrimaryLocationPath, s.SortOrder, s.TimeModeID, tm.TimeModeName, s.StartDateTime, s.EndDateTime, s.DurationAmount, s.DurationUnitID, du.DurationUnitName, s.RelativeTimeText, s.TimeConfidenceID, tc.TimeConfidenceName, s.ScenePurposeNotes, s.SceneOutcomeNotes, - s.RevisionStatusID, rs.StatusName AS RevisionStatusName, s.CreatedDate, s.UpdatedDate, s.IsArchived + s.RevisionStatusID, rs.StatusName AS RevisionStatusName, s.CreatedDate, s.UpdatedDate, + s.IsArchived, s.ArchivedDate, s.ArchivedReason FROM dbo.Scenes s INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Books b ON b.BookID = c.BookID @@ -60,9 +63,33 @@ BEGIN ORDER BY so.SceneID, so.SceneOutcomeID; SELECT mt.MetricTypeID, mt.ProjectID, mt.MetricName, mt.Description, mt.MinValue, mt.MaxValue, - mt.DefaultValue, mt.SortOrder, mt.IsActive, CAST(1 AS bit) AS IsEnabledForProject, - (SELECT COUNT(1) FROM dbo.SceneMetricValues smv WHERE smv.MetricTypeID = mt.MetricTypeID) AS ValueCount + mt.DefaultValue, COALESCE(ptms.SortOrder, mt.SortOrder) AS SortOrder, mt.IsActive, + CAST(CASE WHEN mt.IsActive = 1 AND ptms.MetricTypeID IS NOT NULL THEN 1 ELSE 0 END AS bit) AS IsEnabledForProject, + CAST(CASE WHEN mt.ProjectID = @ProjectID THEN 1 ELSE 0 END AS bit) AS IsCustom, + STUFF(( + SELECT N',' + gmp.PresetKey + FROM dbo.GenreMetricPresetMetrics gpmm + INNER JOIN dbo.GenreMetricPresets gmp ON gmp.GenreMetricPresetID = gpmm.GenreMetricPresetID + WHERE gpmm.MetricTypeID = mt.MetricTypeID + ORDER BY gmp.SortOrder, gmp.PresetName + FOR XML PATH(N''), TYPE).value(N'.', N'nvarchar(max)'), 1, 1, N'') AS GenrePresetKeys, + STUFF(( + SELECT N', ' + gmp.PresetName + FROM dbo.GenreMetricPresetMetrics gpmm + INNER JOIN dbo.GenreMetricPresets gmp ON gmp.GenreMetricPresetID = gpmm.GenreMetricPresetID + WHERE gpmm.MetricTypeID = mt.MetricTypeID + ORDER BY gmp.SortOrder, gmp.PresetName + FOR XML PATH(N''), TYPE).value(N'.', N'nvarchar(max)'), 1, 2, N'') AS GenrePresetNames, + ( + SELECT COUNT(1) + FROM dbo.SceneMetricValues smv + INNER JOIN dbo.Scenes s ON s.SceneID = smv.SceneID + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + WHERE smv.MetricTypeID = mt.MetricTypeID AND b.ProjectID = @ProjectID + ) AS ValueCount FROM dbo.SceneMetricTypes mt + LEFT JOIN dbo.ProjectTimelineMetricSettings ptms ON ptms.ProjectID = @ProjectID AND ptms.MetricTypeID = mt.MetricTypeID WHERE mt.ProjectID IS NULL OR mt.ProjectID = @ProjectID OR EXISTS @@ -74,7 +101,7 @@ BEGIN INNER JOIN dbo.Books b ON b.BookID = c.BookID WHERE smv.MetricTypeID = mt.MetricTypeID AND b.ProjectID = @ProjectID ) - ORDER BY mt.SortOrder, mt.MetricName; + ORDER BY COALESCE(ptms.SortOrder, mt.SortOrder), mt.MetricName; SELECT smv.SceneID, smv.MetricTypeID, mt.MetricName, mt.Description, mt.MinValue, mt.MaxValue, mt.DefaultValue, mt.SortOrder, mt.IsActive, smv.Value, smv.Notes @@ -91,7 +118,7 @@ BEGIN FROM dbo.ProjectTimelineSettings WHERE ProjectID = @ProjectID; - SELECT ptms.ProjectID, ptms.MetricTypeID, mt.MetricName, ptms.SortOrder + SELECT ptms.ProjectID, ptms.MetricTypeID, mt.MetricName, ptms.SortOrder, ptms.CreatedDate FROM dbo.ProjectTimelineMetricSettings ptms INNER JOIN dbo.SceneMetricTypes mt ON mt.MetricTypeID = ptms.MetricTypeID WHERE ptms.ProjectID = @ProjectID @@ -105,7 +132,8 @@ BEGIN SELECT pl.PlotLineID, pl.ProjectID, pl.BookID, b.BookTitle, pl.PlotLineName, pl.PlotLineTypeID, plt.TypeName AS PlotLineTypeName, pl.Description, pl.ParentPlotLineID, parent.PlotLineName AS ParentPlotLineName, - pl.SortOrder, pl.Colour, pl.IsMainPlot, pl.IsVisibleOnTimeline, pl.CreatedDate, pl.UpdatedDate, pl.IsArchived + pl.SortOrder, pl.Colour, pl.IsMainPlot, pl.IsVisibleOnTimeline, pl.CreatedDate, pl.UpdatedDate, + pl.IsArchived, pl.ArchivedDate, pl.ArchivedReason FROM dbo.PlotLines pl INNER JOIN dbo.PlotLineTypes plt ON plt.PlotLineTypeID = pl.PlotLineTypeID LEFT JOIN dbo.Books b ON b.BookID = pl.BookID @@ -116,7 +144,7 @@ BEGIN SELECT pt.PlotThreadID, pt.PlotLineID, pl.ProjectID, pl.PlotLineName, pt.ThreadTitle, pt.ThreadTypeID, tt.TypeName AS ThreadTypeName, pt.ThreadStatusID, ts.StatusName AS ThreadStatusName, pt.Importance, pt.Summary, pt.IntroducedSceneID, pt.PlannedResolutionSceneID, pt.ActualResolutionSceneID, - pt.CreatedDate, pt.UpdatedDate, pt.IsArchived + pt.CreatedDate, pt.UpdatedDate, pt.IsArchived, pt.ArchivedDate, pt.ArchivedReason FROM dbo.PlotThreads pt INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID INNER JOIN dbo.ThreadTypes tt ON tt.ThreadTypeID = pt.ThreadTypeID @@ -137,7 +165,7 @@ BEGIN SELECT sa.StoryAssetID, sa.ProjectID, sa.AssetName, sa.AssetKindID, ak.KindName, sa.Description, sa.Importance, sa.CurrentStateID, ast.StateName AS CurrentStateName, sa.CurrentLocationID, l.LocationName AS CurrentLocationName, l.LocationName AS CurrentLocationPath, sa.IsResolved, - sa.ShowInQuickAddBar, sa.CreatedDate, sa.UpdatedDate, sa.IsArchived + sa.ShowInQuickAddBar, sa.CreatedDate, sa.UpdatedDate, sa.IsArchived, sa.ArchivedDate, sa.ArchivedReason FROM dbo.StoryAssets sa INNER JOIN dbo.AssetKinds ak ON ak.AssetKindID = sa.AssetKindID LEFT JOIN dbo.AssetStates ast ON ast.AssetStateID = sa.CurrentStateID @@ -145,9 +173,10 @@ BEGIN WHERE sa.ProjectID = @ProjectID ORDER BY sa.Importance DESC, sa.AssetName; - SELECT ast.AssetStateID, ast.ProjectID, ast.AssetKindID, ast.StateName, ast.SortOrder, ast.Description, + SELECT ast.AssetStateID, ast.ProjectID, ast.AssetKindID, ak.KindName, ast.StateName, ast.SortOrder, ast.Description, ast.IsResolvedState, ast.IsActive FROM dbo.AssetStates ast + LEFT JOIN dbo.AssetKinds ak ON ak.AssetKindID = ast.AssetKindID WHERE ast.ProjectID IS NULL OR ast.ProjectID = @ProjectID OR EXISTS (SELECT 1 FROM dbo.StoryAssets sa WHERE sa.ProjectID = @ProjectID AND sa.CurrentStateID = ast.AssetStateID) @@ -215,7 +244,7 @@ BEGIN SELECT CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart, AgeReferenceSceneID, Height, EyeColour, CharacterImportance, ShowInQuickAddBar, - DefaultDescription, CreatedDate, UpdatedDate, IsArchived + DefaultDescription, CreatedDate, UpdatedDate, IsArchived, ArchivedDate, ArchivedReason FROM dbo.Characters WHERE ProjectID = @ProjectID ORDER BY CharacterImportance DESC, CharacterName; @@ -281,7 +310,8 @@ BEGIN rt.SortOrder AS RelationshipTypeSortOrder, cr.IsPermanent, cr.StartSceneID, cr.EndSceneID, cr.IsKnownToReader, cr.Notes, cr.IsInitialRelationship, cr.InitialBookID, ib.BookTitle AS InitialBookTitle, cr.ReaderInitiallyKnows, cr.InitialRelationshipStateID, irs.StateName AS InitialRelationshipStateName, - cr.InitialIntensity, cr.IsReciprocal, cr.CreatedDate, cr.UpdatedDate, cr.IsArchived + cr.InitialIntensity, cr.IsReciprocal, cr.CreatedDate, cr.UpdatedDate, + cr.IsArchived, cr.ArchivedDate, cr.ArchivedReason FROM dbo.CharacterRelationships cr INNER JOIN dbo.Characters a ON a.CharacterID = cr.CharacterAID INNER JOIN dbo.Characters bch ON bch.CharacterID = cr.CharacterBID @@ -315,7 +345,7 @@ BEGIN SELECT l.LocationID, l.ProjectID, l.ParentLocationID, parent.LocationName AS ParentLocationName, l.LocationName, l.LocationTypeID, lt.TypeName AS LocationTypeName, l.Description, l.LocationName AS LocationPath, CAST(0 AS int) AS Depth, l.ShowInQuickAddBar, - l.CreatedDate, l.UpdatedDate, l.IsArchived + l.CreatedDate, l.UpdatedDate, l.IsArchived, l.ArchivedDate, l.ArchivedReason FROM dbo.Locations l LEFT JOIN dbo.Locations parent ON parent.LocationID = l.ParentLocationID LEFT JOIN dbo.LocationTypes lt ON lt.LocationTypeID = l.LocationTypeID @@ -426,7 +456,8 @@ BEGIN ORDER BY b.SortOrder, c.SortOrder; SELECT sc.ScenarioID, sc.ProjectID, sc.BookID, sc.ScenarioName, sc.Description, sc.BaseCreatedDate, - sc.IsAppliedToMain, sc.IsArchived, sc.CreatedDate, sc.UpdatedDate, p.ProjectName, b.BookTitle, + sc.IsAppliedToMain, sc.IsArchived, sc.ArchivedDate, sc.ArchivedReason, + sc.CreatedDate, sc.UpdatedDate, p.ProjectName, b.BookTitle, (SELECT COUNT(1) FROM dbo.ScenarioSceneOrder sso WHERE sso.ScenarioID = sc.ScenarioID) AS SceneCount, (SELECT COUNT(1) FROM dbo.ScenarioWarnings sw WHERE sw.ScenarioID = sc.ScenarioID) AS WarningCount FROM dbo.Scenarios sc @@ -454,5 +485,34 @@ BEGIN INNER JOIN dbo.Scenarios sc ON sc.ScenarioID = sw.ScenarioID WHERE sc.ProjectID = @ProjectID ORDER BY sw.CreatedDate DESC, sw.ScenarioWarningID; + + SELECT GenreMetricPresetID, PresetKey, PresetName, Description, SortOrder, IsActive + FROM dbo.GenreMetricPresets + ORDER BY SortOrder, PresetName; + + SELECT gpmm.GenreMetricPresetID, gmp.PresetKey, gmp.PresetName, + gpmm.MetricTypeID, mt.MetricName, gpmm.SortOrder + FROM dbo.GenreMetricPresetMetrics gpmm + INNER JOIN dbo.GenreMetricPresets gmp ON gmp.GenreMetricPresetID = gpmm.GenreMetricPresetID + INNER JOIN dbo.SceneMetricTypes mt ON mt.MetricTypeID = gpmm.MetricTypeID + WHERE EXISTS + ( + SELECT 1 + FROM dbo.SceneMetricTypes projectMetric + WHERE projectMetric.MetricTypeID = gpmm.MetricTypeID + AND + ( + projectMetric.ProjectID IS NULL + OR projectMetric.ProjectID = @ProjectID + OR EXISTS + ( + SELECT 1 + FROM dbo.ProjectTimelineMetricSettings ptms + WHERE ptms.ProjectID = @ProjectID + AND ptms.MetricTypeID = projectMetric.MetricTypeID + ) + ) + ) + ORDER BY gmp.SortOrder, gpmm.SortOrder, mt.MetricName; END; GO diff --git a/PlotLine/Sql/035_ProjectRestorePoints.sql b/PlotLine/Sql/035_ProjectRestorePoints.sql index 8e2b277..f58c193 100644 --- a/PlotLine/Sql/035_ProjectRestorePoints.sql +++ b/PlotLine/Sql/035_ProjectRestorePoints.sql @@ -95,3 +95,41 @@ BEGIN WHERE rp.ProjectRestorePointID = @ProjectRestorePointID; END; GO + +CREATE OR ALTER PROCEDURE dbo.ProjectRestorePoint_Count + @ProjectID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT COUNT(1) + FROM dbo.ProjectRestorePoints + WHERE ProjectID = @ProjectID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ProjectRestorePoint_Delete + @ProjectRestorePointID int, + @ProjectID int +AS +BEGIN + SET NOCOUNT ON; + + IF OBJECT_ID(N'dbo.ProjectRestoreHistory', N'U') IS NOT NULL + BEGIN + UPDATE dbo.ProjectRestoreHistory + SET SourceRestorePointID = NULL + WHERE SourceRestorePointID = @ProjectRestorePointID; + + UPDATE dbo.ProjectRestoreHistory + SET SafetyRestorePointID = NULL + WHERE SafetyRestorePointID = @ProjectRestorePointID; + END; + + DELETE FROM dbo.ProjectRestorePoints + WHERE ProjectRestorePointID = @ProjectRestorePointID + AND ProjectID = @ProjectID; + + SELECT CAST(CASE WHEN @@ROWCOUNT = 1 THEN 1 ELSE 0 END AS bit); +END; +GO diff --git a/PlotLine/Sql/036_ProjectRestoreApply.sql b/PlotLine/Sql/036_ProjectRestoreApply.sql index ff7870c..a11d260 100644 --- a/PlotLine/Sql/036_ProjectRestoreApply.sql +++ b/PlotLine/Sql/036_ProjectRestoreApply.sql @@ -9,8 +9,8 @@ BEGIN ( ProjectRestoreHistoryID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProjectRestoreHistory PRIMARY KEY, ProjectID int NOT NULL, - SourceRestorePointID int NOT NULL, - SafetyRestorePointID int NOT NULL, + SourceRestorePointID int NULL, + SafetyRestorePointID int NULL, RestoredDateUtc datetime2 NOT NULL CONSTRAINT DF_ProjectRestoreHistory_RestoredDateUtc DEFAULT SYSUTCDATETIME(), PerformedBy nvarchar(100) NULL, Notes nvarchar(max) NULL, @@ -21,6 +21,28 @@ BEGIN END; GO +IF OBJECT_ID(N'dbo.ProjectRestoreHistory', N'U') IS NOT NULL +BEGIN + IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_ProjectRestoreHistory_SourceRestorePoint' AND parent_object_id = OBJECT_ID(N'dbo.ProjectRestoreHistory')) + ALTER TABLE dbo.ProjectRestoreHistory DROP CONSTRAINT FK_ProjectRestoreHistory_SourceRestorePoint; + + IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_ProjectRestoreHistory_SafetyRestorePoint' AND parent_object_id = OBJECT_ID(N'dbo.ProjectRestoreHistory')) + ALTER TABLE dbo.ProjectRestoreHistory DROP CONSTRAINT FK_ProjectRestoreHistory_SafetyRestorePoint; + + IF EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.ProjectRestoreHistory') AND name = N'SourceRestorePointID' AND is_nullable = 0) + ALTER TABLE dbo.ProjectRestoreHistory ALTER COLUMN SourceRestorePointID int NULL; + + IF EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.ProjectRestoreHistory') AND name = N'SafetyRestorePointID' AND is_nullable = 0) + ALTER TABLE dbo.ProjectRestoreHistory ALTER COLUMN SafetyRestorePointID int NULL; + + IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_ProjectRestoreHistory_SourceRestorePoint' AND parent_object_id = OBJECT_ID(N'dbo.ProjectRestoreHistory')) + ALTER TABLE dbo.ProjectRestoreHistory ADD CONSTRAINT FK_ProjectRestoreHistory_SourceRestorePoint FOREIGN KEY(SourceRestorePointID) REFERENCES dbo.ProjectRestorePoints(ProjectRestorePointID); + + IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_ProjectRestoreHistory_SafetyRestorePoint' AND parent_object_id = OBJECT_ID(N'dbo.ProjectRestoreHistory')) + ALTER TABLE dbo.ProjectRestoreHistory ADD CONSTRAINT FK_ProjectRestoreHistory_SafetyRestorePoint FOREIGN KEY(SafetyRestorePointID) REFERENCES dbo.ProjectRestorePoints(ProjectRestorePointID); +END; +GO + IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_ProjectRestoreHistory_ProjectDate' AND object_id = OBJECT_ID(N'dbo.ProjectRestoreHistory')) CREATE INDEX IX_ProjectRestoreHistory_ProjectDate ON dbo.ProjectRestoreHistory(ProjectID, RestoredDateUtc DESC); GO @@ -117,8 +139,8 @@ GO CREATE OR ALTER PROCEDURE dbo.ProjectRestore_ApplyFromJson @ProjectID int, - @SourceRestorePointID int, - @SafetyRestorePointID int, + @SourceRestorePointID int = NULL, + @SafetyRestorePointID int = NULL, @JsonData nvarchar(max) AS BEGIN @@ -231,7 +253,9 @@ BEGIN Description = src.Description, CreatedDate = src.CreatedDate, UpdatedDate = SYSUTCDATETIME(), - IsArchived = src.IsArchived + IsArchived = src.IsArchived, + ArchivedDate = src.ArchivedDate, + ArchivedReason = src.ArchivedReason FROM dbo.Projects p CROSS APPLY OPENJSON(@JsonData, '$.Project') WITH @@ -240,7 +264,9 @@ BEGIN ProjectName nvarchar(200) '$.ProjectName', Description nvarchar(max) '$.Description', CreatedDate datetime2 '$.CreatedDate', - IsArchived bit '$.IsArchived' + IsArchived bit '$.IsArchived', + ArchivedDate datetime2 '$.ArchivedDate', + ArchivedReason nvarchar(1000) '$.ArchivedReason' ) src WHERE p.ProjectID = @ProjectID AND src.ProjectID = @ProjectID; @@ -298,9 +324,144 @@ BEGIN CLOSE EnableCursor; DEALLOCATE EnableCursor; - INSERT INTO dbo.ProjectRestoreHistory (ProjectID, SourceRestorePointID, SafetyRestorePointID, PerformedBy, Notes) - VALUES (@ProjectID, @SourceRestorePointID, @SafetyRestorePointID, SUSER_SNAME(), N'Project restored from saved restore point.'); + IF @SourceRestorePointID IS NOT NULL AND @SafetyRestorePointID IS NOT NULL + BEGIN + INSERT INTO dbo.ProjectRestoreHistory (ProjectID, SourceRestorePointID, SafetyRestorePointID, PerformedBy, Notes) + VALUES (@ProjectID, @SourceRestorePointID, @SafetyRestorePointID, SUSER_SNAME(), N'Project restored from saved restore point.'); + END; COMMIT TRANSACTION; END; GO + +CREATE OR ALTER PROCEDURE dbo.ProjectBackup_GetIdentityMaxValues + @TableNames nvarchar(max) +AS +BEGIN + SET NOCOUNT ON; + + SELECT t.name AS TableName, + ISNULL(CONVERT(int, IDENT_CURRENT(N'dbo.' + t.name)), 0) AS MaxID + FROM sys.tables t + INNER JOIN string_split(@TableNames, N',') requested ON requested.value = t.name + WHERE SCHEMA_NAME(t.schema_id) = N'dbo'; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ProjectBackup_GetRelationshipImportLookups +AS +BEGIN + SET NOCOUNT ON; + + SELECT RevisionStatusID AS LookupID, StatusName AS LookupName FROM dbo.RevisionStatuses ORDER BY SortOrder, StatusName; + SELECT TimeModeID AS LookupID, TimeModeName AS LookupName FROM dbo.TimeModes ORDER BY SortOrder, TimeModeName; + SELECT DurationUnitID AS LookupID, DurationUnitName AS LookupName FROM dbo.DurationUnits ORDER BY SortOrder, DurationUnitName; + SELECT TimeConfidenceID AS LookupID, TimeConfidenceName AS LookupName FROM dbo.TimeConfidences ORDER BY SortOrder, TimeConfidenceName; + SELECT LocationTypeID AS LookupID, TypeName AS LookupName FROM dbo.LocationTypes WHERE IsActive = 1 ORDER BY SortOrder, TypeName; + SELECT LocationRelationshipTypeID AS LookupID, TypeName AS LookupName FROM dbo.LocationRelationshipTypes WHERE IsActive = 1 ORDER BY SortOrder, TypeName; + SELECT PlotLineTypeID AS LookupID, TypeName AS LookupName FROM dbo.PlotLineTypes WHERE IsActive = 1 ORDER BY SortOrder, TypeName; + SELECT ThreadTypeID AS LookupID, TypeName AS LookupName FROM dbo.ThreadTypes WHERE IsActive = 1 ORDER BY SortOrder, TypeName; + SELECT ThreadStatusID AS LookupID, StatusName AS LookupName FROM dbo.ThreadStatuses WHERE IsActive = 1 ORDER BY SortOrder, StatusName; + SELECT ThreadEventTypeID AS LookupID, TypeName AS LookupName FROM dbo.ThreadEventTypes WHERE IsActive = 1 ORDER BY SortOrder, TypeName; + SELECT AssetKindID AS LookupID, KindName AS LookupName FROM dbo.AssetKinds WHERE IsActive = 1 ORDER BY SortOrder, KindName; + SELECT AssetStateID AS LookupID, StateName AS LookupName FROM dbo.AssetStates WHERE IsActive = 1 AND ProjectID IS NULL ORDER BY SortOrder, StateName; + SELECT AssetEventTypeID AS LookupID, TypeName AS LookupName FROM dbo.AssetEventTypes WHERE IsActive = 1 ORDER BY SortOrder, TypeName; + SELECT AssetDependencyTypeID AS LookupID, TypeName AS LookupName FROM dbo.AssetDependencyTypes WHERE IsActive = 1 ORDER BY SortOrder, TypeName; + SELECT AssetCustodyEventTypeID AS LookupID, TypeName AS LookupName FROM dbo.AssetCustodyEventTypes WHERE IsActive = 1 ORDER BY SortOrder, TypeName; + SELECT CustodyRoleID AS LookupID, RoleName AS LookupName FROM dbo.CustodyRoles WHERE IsActive = 1 ORDER BY SortOrder, RoleName; + SELECT CharacterRoleInSceneTypeID AS LookupID, TypeName AS LookupName FROM dbo.CharacterRoleInSceneTypes WHERE IsActive = 1 ORDER BY SortOrder, TypeName; + SELECT PresenceTypeID AS LookupID, TypeName AS LookupName FROM dbo.PresenceTypes WHERE IsActive = 1 ORDER BY SortOrder, TypeName; + SELECT KnowledgeStateID AS LookupID, StateName AS LookupName FROM dbo.KnowledgeStates WHERE IsActive = 1 ORDER BY SortOrder, StateName; + SELECT SceneDependencyTypeID AS LookupID, TypeName AS LookupName FROM dbo.SceneDependencyTypes WHERE IsActive = 1 ORDER BY SortOrder, TypeName; + SELECT SceneNoteTypeID AS LookupID, TypeName AS LookupName FROM dbo.SceneNoteTypes WHERE IsActive = 1 ORDER BY SortOrder, TypeName; + SELECT SceneAttachmentTypeID AS LookupID, TypeName AS LookupName FROM dbo.SceneAttachmentTypes WHERE IsActive = 1 ORDER BY SortOrder, TypeName; + SELECT ScenePurposeTypeID AS LookupID, PurposeName AS LookupName FROM dbo.ScenePurposeTypes ORDER BY SortOrder, PurposeName; + SELECT MetricTypeID AS LookupID, MetricName AS LookupName FROM dbo.SceneMetricTypes WHERE ProjectID IS NULL ORDER BY SortOrder, MetricName; + + SELECT rt.RelationshipTypeID, rt.TypeName, rt.IsPermanentDefault, rt.SortOrder, rt.IsActive, + rt.RelationshipCategoryID, rc.CategoryName AS RelationshipCategoryName, COALESCE(rc.SortOrder, 900) AS CategorySortOrder + FROM dbo.RelationshipTypes rt + LEFT JOIN dbo.RelationshipCategories rc ON rc.RelationshipCategoryID = rt.RelationshipCategoryID + WHERE rt.IsActive = 1 + ORDER BY COALESCE(rc.SortOrder, 900), rt.SortOrder, rt.TypeName; + + SELECT RelationshipStateID, StateName, SortOrder, IsActive + FROM dbo.RelationshipStates + WHERE IsActive = 1 + ORDER BY SortOrder, StateName; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ProjectBackup_RelationshipImportDiagnostics + @ProjectID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT + (SELECT COUNT(1) + FROM dbo.Characters c + WHERE c.ProjectID = @ProjectID) AS CharacterCount, + (SELECT COUNT(1) + FROM dbo.CharacterRelationships cr + WHERE cr.ProjectID = @ProjectID) AS RelationshipCount, + (SELECT COUNT(1) + FROM dbo.RelationshipEvents re + INNER JOIN dbo.CharacterRelationships cr ON cr.CharacterRelationshipID = re.CharacterRelationshipID + WHERE cr.ProjectID = @ProjectID) AS RelationshipEventCount, + (SELECT COUNT(1) + FROM dbo.CharacterRelationships cr + INNER JOIN dbo.Characters ca ON ca.CharacterID = cr.CharacterAID AND ca.ProjectID = @ProjectID AND ca.IsArchived = 0 + INNER JOIN dbo.Characters cb ON cb.CharacterID = cr.CharacterBID AND cb.ProjectID = @ProjectID AND cb.IsArchived = 0 + INNER JOIN dbo.RelationshipTypes rt ON rt.RelationshipTypeID = cr.RelationshipTypeID AND rt.IsActive = 1 + LEFT JOIN dbo.RelationshipCategories rc ON rc.RelationshipCategoryID = rt.RelationshipCategoryID + WHERE cr.ProjectID = @ProjectID + AND cr.IsArchived = 0) AS MapRelationshipCount, + (SELECT COUNT(1) + FROM dbo.CharacterRelationships cr + LEFT JOIN dbo.Characters ca ON ca.CharacterID = cr.CharacterAID AND ca.ProjectID = @ProjectID + WHERE cr.ProjectID = @ProjectID + AND ca.CharacterID IS NULL) AS MissingSourceCharacterCount, + (SELECT COUNT(1) + FROM dbo.CharacterRelationships cr + LEFT JOIN dbo.Characters cb ON cb.CharacterID = cr.CharacterBID AND cb.ProjectID = @ProjectID + WHERE cr.ProjectID = @ProjectID + AND cb.CharacterID IS NULL) AS MissingTargetCharacterCount, + (SELECT COUNT(1) + FROM dbo.CharacterRelationships cr + LEFT JOIN dbo.RelationshipTypes rt ON rt.RelationshipTypeID = cr.RelationshipTypeID + WHERE cr.ProjectID = @ProjectID + AND rt.RelationshipTypeID IS NULL) AS MissingRelationshipTypeCount, + (SELECT COUNT(1) + FROM dbo.CharacterRelationships cr + LEFT JOIN dbo.RelationshipStates rs ON rs.RelationshipStateID = cr.InitialRelationshipStateID + WHERE cr.ProjectID = @ProjectID + AND cr.InitialRelationshipStateID IS NOT NULL + AND rs.RelationshipStateID IS NULL) AS MissingInitialStateCount, + (SELECT COUNT(1) + FROM dbo.RelationshipEvents re + LEFT JOIN dbo.CharacterRelationships cr ON cr.CharacterRelationshipID = re.CharacterRelationshipID AND cr.ProjectID = @ProjectID + WHERE cr.CharacterRelationshipID IS NULL) AS MissingEventRelationshipCount, + (SELECT COUNT(1) + FROM dbo.RelationshipEvents re + INNER JOIN dbo.CharacterRelationships cr ON cr.CharacterRelationshipID = re.CharacterRelationshipID AND cr.ProjectID = @ProjectID + LEFT JOIN dbo.Scenes s ON s.SceneID = re.SceneID + WHERE s.SceneID IS NULL) AS MissingEventSceneCount, + (SELECT COUNT(1) + FROM dbo.RelationshipEvents re + INNER JOIN dbo.CharacterRelationships cr ON cr.CharacterRelationshipID = re.CharacterRelationshipID AND cr.ProjectID = @ProjectID + LEFT JOIN dbo.RelationshipStates rs ON rs.RelationshipStateID = re.RelationshipStateID + WHERE rs.RelationshipStateID IS NULL) AS MissingEventStateCount, + (SELECT COUNT(1) + FROM dbo.CharacterRelationships cr + WHERE cr.ProjectID = @ProjectID + AND cr.IsArchived = 1) AS ArchivedRelationshipCount, + (SELECT COUNT(1) + FROM dbo.CharacterRelationships cr + INNER JOIN dbo.Characters ca ON ca.CharacterID = cr.CharacterAID AND ca.ProjectID = @ProjectID + INNER JOIN dbo.Characters cb ON cb.CharacterID = cr.CharacterBID AND cb.ProjectID = @ProjectID + WHERE cr.ProjectID = @ProjectID + AND cr.IsArchived = 0 + AND (ca.IsArchived = 1 OR cb.IsArchived = 1)) AS ArchivedCharacterEndpointCount; +END; +GO diff --git a/PlotLine/Views/Imports/Index.cshtml b/PlotLine/Views/Imports/Index.cshtml index aff4cfb..9d83d17 100644 --- a/PlotLine/Views/Imports/Index.cshtml +++ b/PlotLine/Views/Imports/Index.cshtml @@ -8,7 +8,7 @@

Tools

JSON Import

-

Paste or upload a Phase 1G package, preview it, dry-run if needed, then import a new project.

+

Paste or upload a legacy JSON package or a PlotWeaver backup file, preview it, dry-run if needed, then import a new project.

Download sample JSON @@ -39,13 +39,13 @@
- - + +
Uploading a file replaces the pasted text for this preview.
- +
diff --git a/PlotLine/Views/ProjectRestorePoints/Index.cshtml b/PlotLine/Views/ProjectRestorePoints/Index.cshtml index e6631f8..460eb03 100644 --- a/PlotLine/Views/ProjectRestorePoints/Index.cshtml +++ b/PlotLine/Views/ProjectRestorePoints/Index.cshtml @@ -2,6 +2,8 @@ @{ ViewData["Title"] = "Restore Points"; var defaultRestorePointName = $"Manual restore point - {DateTime.Now:yyyy-MM-dd HH:mm}"; + var restorePointLimitReached = Model.RestorePoints.Count >= 10; + const string restorePointLimitMessage = "You have reached the limit of 10 restore points for this project. Delete an old restore point before creating a new one. You can download a restore point first if you want to keep a local backup copy."; } @functions { @@ -50,18 +52,22 @@

Create restore point

- + @if (restorePointLimitReached) + { +
@restorePointLimitMessage
+ } +
- +
- +
- +
@@ -98,6 +104,7 @@ Download + } @@ -130,6 +137,29 @@ + + } }