Backup / restore / export / import working.

This commit is contained in:
Nick Beckley 2026-06-05 21:14:53 +01:00
parent f8bf5af17f
commit cbadc9635d
15 changed files with 1612 additions and 35 deletions

View File

@ -29,7 +29,26 @@ foreach (var batch in batches)
await using var command = new SqlCommand(batch, connection); await using var command = new SqlCommand(batch, connection);
command.CommandTimeout = 120; 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++; count++;
} }

View File

@ -20,6 +20,12 @@ public sealed class ProjectRestorePointsController(
{ {
try try
{ {
if (await restorePoints.HasReachedManualLimitAsync(projectId))
{
TempData["ProjectRestorePointMessage"] = ProjectRestorePointService.LimitReachedMessage;
return RedirectToAction(nameof(Index), new { projectId });
}
var created = await restorePoints.CreateManualAsync(projectId, restorePointName, notes); var created = await restorePoints.CreateManualAsync(projectId, restorePointName, notes);
TempData["ProjectRestorePointMessage"] = created is null TempData["ProjectRestorePointMessage"] = created is null
? "Restore point could not be created because the project was not found." ? "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 }); return RedirectToAction(nameof(Index), new { projectId });
} }
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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 });
}
} }

View File

@ -9,6 +9,11 @@ public interface IImportRepository
{ {
Task<bool> ProjectTitleExistsAsync(string projectTitle); Task<bool> ProjectTitleExistsAsync(string projectTitle);
Task<ImportResult> ImportAsync(PlotLineImportPackage package, string? importedProjectTitle); Task<ImportResult> ImportAsync(PlotLineImportPackage package, string? importedProjectTitle);
Task<IReadOnlyDictionary<string, int>> GetIdentityMaxValuesAsync(IEnumerable<string> tableNames);
Task<ProjectBackupRelationshipImportLookups> GetRelationshipImportLookupsAsync();
Task<ProjectBackupRelationshipImportDiagnostics> GetRelationshipImportDiagnosticsAsync(int projectId);
Task<int> CreateProjectShellAsync(string projectName, string? description);
Task ApplyProjectBackupJsonAsync(int projectId, string jsonData);
} }
public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : IImportRepository public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : IImportRepository
@ -29,6 +34,90 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
return count > 0; return count > 0;
} }
public async Task<IReadOnlyDictionary<string, int>> GetIdentityMaxValuesAsync(IEnumerable<string> tableNames)
{
using var connection = connectionFactory.CreateConnection();
var requestedTables = tableNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var rows = await connection.QueryAsync<IdentityMaxRow>(
"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<ProjectBackupRelationshipImportLookups> 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<ProjectBackupLookupRow>()).ToList(),
TimeModes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
DurationUnits = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
TimeConfidences = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
LocationTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
LocationRelationshipTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
PlotLineTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
ThreadTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
ThreadStatuses = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
ThreadEventTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
AssetKinds = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
AssetStates = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
AssetEventTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
AssetDependencyTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
AssetCustodyEventTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
CustodyRoles = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
CharacterRoleTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
PresenceTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
KnowledgeStates = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
SceneDependencyTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
SceneNoteTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
SceneAttachmentTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
ScenePurposeTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
SceneMetricTypes = (await result.ReadAsync<ProjectBackupLookupRow>()).ToList(),
RelationshipTypes = (await result.ReadAsync<RelationshipType>()).ToList(),
RelationshipStates = (await result.ReadAsync<RelationshipState>()).ToList()
};
}
public async Task<ProjectBackupRelationshipImportDiagnostics> GetRelationshipImportDiagnosticsAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<ProjectBackupRelationshipImportDiagnostics>(
"dbo.ProjectBackup_RelationshipImportDiagnostics",
new { ProjectID = projectId },
commandType: CommandType.StoredProcedure);
}
public async Task<int> CreateProjectShellAsync(string projectName, string? description)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"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<ImportResult> ImportAsync(PlotLineImportPackage package, string? importedProjectTitle) public async Task<ImportResult> ImportAsync(PlotLineImportPackage package, string? importedProjectTitle)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
@ -1322,4 +1411,10 @@ public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) :
public int LookupID { get; set; } public int LookupID { get; set; }
public string StatusName { get; set; } = string.Empty; public string StatusName { get; set; } = string.Empty;
} }
private sealed class IdentityMaxRow
{
public string TableName { get; set; } = string.Empty;
public int MaxID { get; set; }
}
} }

View File

@ -62,7 +62,9 @@ public sealed class ProjectBackupRepository(ISqlConnectionFactory connectionFact
Scenarios = (await result.ReadAsync<Scenario>()).ToList(), Scenarios = (await result.ReadAsync<Scenario>()).ToList(),
ScenarioSceneOrder = (await result.ReadAsync<ProjectBackupScenarioSceneOrder>()).ToList(), ScenarioSceneOrder = (await result.ReadAsync<ProjectBackupScenarioSceneOrder>()).ToList(),
ScenarioValidationRuns = (await result.ReadAsync<ProjectBackupScenarioValidationRun>()).ToList(), ScenarioValidationRuns = (await result.ReadAsync<ProjectBackupScenarioValidationRun>()).ToList(),
ScenarioWarnings = (await result.ReadAsync<ScenarioWarning>()).ToList() ScenarioWarnings = (await result.ReadAsync<ScenarioWarning>()).ToList(),
GenreMetricPresets = (await result.ReadAsync<GenreMetricPreset>()).ToList(),
GenreMetricPresetMetrics = (await result.ReadAsync<ProjectBackupGenreMetricPresetMetric>()).ToList()
}; };
if (project is null) if (project is null)
@ -120,7 +122,9 @@ public sealed class ProjectBackupRepository(ISqlConnectionFactory connectionFact
nameof(data.Scenarios), nameof(data.Scenarios),
nameof(data.ScenarioSceneOrder), nameof(data.ScenarioSceneOrder),
nameof(data.ScenarioValidationRuns), 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." Note = "Original database IDs are included for diagnostics. Future import/restore should map by logical project structure and names where practical."
} }

View File

@ -8,7 +8,9 @@ public interface IProjectRestorePointRepository
{ {
Task<IReadOnlyList<ProjectRestorePoint>> ListAsync(int projectId); Task<IReadOnlyList<ProjectRestorePoint>> ListAsync(int projectId);
Task<ProjectRestorePoint?> GetAsync(int projectRestorePointId, int projectId); Task<ProjectRestorePoint?> GetAsync(int projectRestorePointId, int projectId);
Task<int> CountAsync(int projectId);
Task<ProjectRestorePoint> CreateAsync(int projectId, string restorePointName, string restorePointType, string jsonData, string? notes); Task<ProjectRestorePoint> CreateAsync(int projectId, string restorePointName, string restorePointType, string jsonData, string? notes);
Task<bool> DeleteAsync(int projectRestorePointId, int projectId);
Task RestoreAsync(int projectId, int sourceRestorePointId, int safetyRestorePointId, string jsonData); Task RestoreAsync(int projectId, int sourceRestorePointId, int safetyRestorePointId, string jsonData);
} }
@ -33,6 +35,15 @@ public sealed class ProjectRestorePointRepository(ISqlConnectionFactory connecti
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
public async Task<int> CountAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<int>(
"dbo.ProjectRestorePoint_Count",
new { ProjectID = projectId },
commandType: CommandType.StoredProcedure);
}
public async Task<ProjectRestorePoint> CreateAsync(int projectId, string restorePointName, string restorePointType, string jsonData, string? notes) public async Task<ProjectRestorePoint> CreateAsync(int projectId, string restorePointName, string restorePointType, string jsonData, string? notes)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();
@ -49,6 +60,15 @@ public sealed class ProjectRestorePointRepository(ISqlConnectionFactory connecti
commandType: CommandType.StoredProcedure); commandType: CommandType.StoredProcedure);
} }
public async Task<bool> DeleteAsync(int projectRestorePointId, int projectId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<bool>(
"dbo.ProjectRestorePoint_Delete",
new { ProjectRestorePointID = projectRestorePointId, ProjectID = projectId },
commandType: CommandType.StoredProcedure);
}
public async Task RestoreAsync(int projectId, int sourceRestorePointId, int safetyRestorePointId, string jsonData) public async Task RestoreAsync(int projectId, int sourceRestorePointId, int safetyRestorePointId, string jsonData)
{ {
using var connection = connectionFactory.CreateConnection(); using var connection = connectionFactory.CreateConnection();

View File

@ -8,6 +8,8 @@ public sealed class Project
public DateTime CreatedDate { get; set; } public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; } public DateTime UpdatedDate { get; set; }
public bool IsArchived { get; set; } public bool IsArchived { get; set; }
public DateTime? ArchivedDate { get; set; }
public string? ArchivedReason { get; set; }
} }
public sealed class GenreMetricPreset public sealed class GenreMetricPreset
@ -31,6 +33,8 @@ public sealed class Book
public DateTime CreatedDate { get; set; } public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; } public DateTime UpdatedDate { get; set; }
public bool IsArchived { get; set; } public bool IsArchived { get; set; }
public DateTime? ArchivedDate { get; set; }
public string? ArchivedReason { get; set; }
} }
public sealed class Chapter public sealed class Chapter
@ -47,6 +51,8 @@ public sealed class Chapter
public DateTime CreatedDate { get; set; } public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; } public DateTime UpdatedDate { get; set; }
public bool IsArchived { get; set; } public bool IsArchived { get; set; }
public DateTime? ArchivedDate { get; set; }
public string? ArchivedReason { get; set; }
} }
public sealed class Scene public sealed class Scene
@ -78,6 +84,8 @@ public sealed class Scene
public DateTime CreatedDate { get; set; } public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; } public DateTime UpdatedDate { get; set; }
public bool IsArchived { get; set; } public bool IsArchived { get; set; }
public DateTime? ArchivedDate { get; set; }
public string? ArchivedReason { get; set; }
public List<int> SelectedPurposeTypeIds { get; set; } = []; public List<int> SelectedPurposeTypeIds { get; set; } = [];
public List<ScenePurposeLabel> PurposeLabels { get; set; } = []; public List<ScenePurposeLabel> PurposeLabels { get; set; } = [];
public List<SceneMetricValue> Metrics { get; set; } = []; public List<SceneMetricValue> Metrics { get; set; } = [];
@ -153,6 +161,9 @@ public sealed class SceneMetricType
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public bool IsEnabledForProject { 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; } public int ValueCount { get; set; }
} }
@ -215,6 +226,7 @@ public sealed class ProjectTimelineMetricSetting
public int MetricTypeID { get; set; } public int MetricTypeID { get; set; }
public string MetricName { get; set; } = string.Empty; public string MetricName { get; set; } = string.Empty;
public int SortOrder { get; set; } public int SortOrder { get; set; }
public DateTime CreatedDate { get; set; }
} }
public sealed class PlotLineType public sealed class PlotLineType
@ -244,6 +256,8 @@ public sealed class PlotLineItem
public DateTime CreatedDate { get; set; } public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; } public DateTime UpdatedDate { get; set; }
public bool IsArchived { get; set; } public bool IsArchived { get; set; }
public DateTime? ArchivedDate { get; set; }
public string? ArchivedReason { get; set; }
} }
public sealed class ThreadType public sealed class ThreadType
@ -283,6 +297,8 @@ public sealed class PlotThread
public DateTime CreatedDate { get; set; } public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; } public DateTime UpdatedDate { get; set; }
public bool IsArchived { get; set; } public bool IsArchived { get; set; }
public DateTime? ArchivedDate { get; set; }
public string? ArchivedReason { get; set; }
} }
public sealed class ThreadEventType public sealed class ThreadEventType
@ -343,6 +359,7 @@ public sealed class AssetState
public int AssetStateID { get; set; } public int AssetStateID { get; set; }
public int? ProjectID { get; set; } public int? ProjectID { get; set; }
public int? AssetKindID { get; set; } public int? AssetKindID { get; set; }
public string? KindName { get; set; }
public string StateName { get; set; } = string.Empty; public string StateName { get; set; } = string.Empty;
public int SortOrder { get; set; } public int SortOrder { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
@ -369,6 +386,8 @@ public sealed class StoryAsset
public DateTime CreatedDate { get; set; } public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; } public DateTime UpdatedDate { get; set; }
public bool IsArchived { get; set; } public bool IsArchived { get; set; }
public DateTime? ArchivedDate { get; set; }
public string? ArchivedReason { get; set; }
} }
public sealed class AssetEventType public sealed class AssetEventType
@ -433,6 +452,8 @@ public sealed class AssetDependency
public DateTime CreatedDate { get; set; } public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; } public DateTime UpdatedDate { get; set; }
public bool IsArchived { get; set; } public bool IsArchived { get; set; }
public DateTime? ArchivedDate { get; set; }
public string? ArchivedReason { get; set; }
} }
public sealed class AssetCustodyEventType public sealed class AssetCustodyEventType
@ -508,6 +529,8 @@ public sealed class Character
public DateTime CreatedDate { get; set; } public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; } public DateTime UpdatedDate { get; set; }
public bool IsArchived { get; set; } public bool IsArchived { get; set; }
public DateTime? ArchivedDate { get; set; }
public string? ArchivedReason { get; set; }
} }
public sealed class CharacterRoleInSceneType public sealed class CharacterRoleInSceneType
@ -677,6 +700,8 @@ public sealed class CharacterRelationship
public DateTime CreatedDate { get; set; } public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; } public DateTime UpdatedDate { get; set; }
public bool IsArchived { get; set; } public bool IsArchived { get; set; }
public DateTime? ArchivedDate { get; set; }
public string? ArchivedReason { get; set; }
} }
public sealed class RelationshipEvent public sealed class RelationshipEvent
@ -762,6 +787,8 @@ public sealed class LocationItem
public DateTime CreatedDate { get; set; } public DateTime CreatedDate { get; set; }
public DateTime UpdatedDate { get; set; } public DateTime UpdatedDate { get; set; }
public bool IsArchived { get; set; } public bool IsArchived { get; set; }
public DateTime? ArchivedDate { get; set; }
public string? ArchivedReason { get; set; }
} }
public sealed class LocationRelationship public sealed class LocationRelationship

View File

@ -29,6 +29,59 @@ public sealed class ImportAliasesDto
public Dictionary<string, string> Locations { get; set; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary<string, string> Locations { get; set; } = new(StringComparer.OrdinalIgnoreCase);
} }
public sealed class ProjectBackupRelationshipImportLookups
{
public IReadOnlyList<ProjectBackupLookupRow> RevisionStatuses { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> TimeModes { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> DurationUnits { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> TimeConfidences { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> LocationTypes { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> LocationRelationshipTypes { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> PlotLineTypes { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> ThreadTypes { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> ThreadStatuses { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> ThreadEventTypes { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> AssetKinds { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> AssetStates { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> AssetEventTypes { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> AssetDependencyTypes { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> AssetCustodyEventTypes { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> CustodyRoles { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> CharacterRoleTypes { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> PresenceTypes { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> KnowledgeStates { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> SceneDependencyTypes { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> SceneNoteTypes { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> SceneAttachmentTypes { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> ScenePurposeTypes { get; init; } = [];
public IReadOnlyList<ProjectBackupLookupRow> SceneMetricTypes { get; init; } = [];
public IReadOnlyList<RelationshipType> RelationshipTypes { get; init; } = [];
public IReadOnlyList<RelationshipState> 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 sealed class ImportProjectDto
{ {
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;

View File

@ -54,6 +54,8 @@ public sealed class ProjectBackupData
public IReadOnlyList<ProjectBackupScenarioSceneOrder> ScenarioSceneOrder { get; init; } = []; public IReadOnlyList<ProjectBackupScenarioSceneOrder> ScenarioSceneOrder { get; init; } = [];
public IReadOnlyList<ProjectBackupScenarioValidationRun> ScenarioValidationRuns { get; init; } = []; public IReadOnlyList<ProjectBackupScenarioValidationRun> ScenarioValidationRuns { get; init; } = [];
public IReadOnlyList<ScenarioWarning> ScenarioWarnings { get; init; } = []; public IReadOnlyList<ScenarioWarning> ScenarioWarnings { get; init; } = [];
public IReadOnlyList<GenreMetricPreset> GenreMetricPresets { get; init; } = [];
public IReadOnlyList<ProjectBackupGenreMetricPresetMetric> GenreMetricPresetMetrics { get; init; } = [];
} }
public sealed class ProjectBackupDiagnostics public sealed class ProjectBackupDiagnostics
@ -130,3 +132,13 @@ public sealed class ProjectBackupScenarioValidationRun
public DateTime? CompletedDate { get; set; } public DateTime? CompletedDate { get; set; }
public int WarningCount { 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; }
}

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,8 @@ public interface IProjectRestorePointService
Task<ProjectRestorePoint?> CreateManualAsync(int projectId, string? restorePointName, string? notes); Task<ProjectRestorePoint?> CreateManualAsync(int projectId, string? restorePointName, string? notes);
Task<ProjectRestorePoint?> CreatePreRestoreAsync(int projectId, string sourceRestorePointName); Task<ProjectRestorePoint?> CreatePreRestoreAsync(int projectId, string sourceRestorePointName);
Task<ProjectRestorePointDownload?> DownloadAsync(int projectRestorePointId, int projectId); Task<ProjectRestorePointDownload?> DownloadAsync(int projectRestorePointId, int projectId);
Task<bool> HasReachedManualLimitAsync(int projectId);
Task<bool> DeleteAsync(int projectRestorePointId, int projectId);
} }
public sealed class ProjectRestorePointService( public sealed class ProjectRestorePointService(
@ -20,6 +22,8 @@ public sealed class ProjectRestorePointService(
{ {
private const string ManualRestorePointType = "Manual"; private const string ManualRestorePointType = "Manual";
private const string PreRestorePointType = "PreRestore"; 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<ProjectRestorePointListViewModel?> GetAsync(int projectId) public async Task<ProjectRestorePointListViewModel?> GetAsync(int projectId)
{ {
@ -38,6 +42,14 @@ public sealed class ProjectRestorePointService(
public async Task<ProjectRestorePoint?> CreateManualAsync(int projectId, string? restorePointName, string? notes) public async Task<ProjectRestorePoint?> 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) var name = string.IsNullOrWhiteSpace(restorePointName)
? $"Manual restore point - {DateTime.Now:yyyy-MM-dd HH:mm}" ? $"Manual restore point - {DateTime.Now:yyyy-MM-dd HH:mm}"
: restorePointName.Trim(); : restorePointName.Trim();
@ -88,6 +100,20 @@ public sealed class ProjectRestorePointService(
}; };
} }
public async Task<bool> HasReachedManualLimitAsync(int projectId)
=> await restorePoints.CountAsync(projectId) >= MaxRestorePointsPerProject;
public async Task<bool> 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) private static string? Clean(string? value)
{ {
return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); return string.IsNullOrWhiteSpace(value) ? null : value.Trim();

View File

@ -9,17 +9,19 @@ AS
BEGIN BEGIN
SET NOCOUNT ON; SET NOCOUNT ON;
SELECT ProjectID, ProjectName, Description, CreatedDate, UpdatedDate, IsArchived SELECT ProjectID, ProjectName, Description, CreatedDate, UpdatedDate, IsArchived, ArchivedDate, ArchivedReason
FROM dbo.Projects FROM dbo.Projects
WHERE ProjectID = @ProjectID; 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 FROM dbo.Books
WHERE ProjectID = @ProjectID WHERE ProjectID = @ProjectID
ORDER BY SortOrder, BookNumber, BookID; ORDER BY SortOrder, BookNumber, BookID;
SELECT c.ChapterID, c.BookID, c.ChapterNumber, c.ChapterTitle, c.POVCharacterID, c.Summary, c.SortOrder, 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 FROM dbo.Chapters c
INNER JOIN dbo.Books b ON b.BookID = c.BookID INNER JOIN dbo.Books b ON b.BookID = c.BookID
INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = c.RevisionStatusID 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, 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.StartDateTime, s.EndDateTime, s.DurationAmount, s.DurationUnitID, du.DurationUnitName,
s.RelativeTimeText, s.TimeConfidenceID, tc.TimeConfidenceName, s.ScenePurposeNotes, s.SceneOutcomeNotes, 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 FROM dbo.Scenes s
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
INNER JOIN dbo.Books b ON b.BookID = c.BookID INNER JOIN dbo.Books b ON b.BookID = c.BookID
@ -60,9 +63,33 @@ BEGIN
ORDER BY so.SceneID, so.SceneOutcomeID; ORDER BY so.SceneID, so.SceneOutcomeID;
SELECT mt.MetricTypeID, mt.ProjectID, mt.MetricName, mt.Description, mt.MinValue, mt.MaxValue, 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, mt.DefaultValue, COALESCE(ptms.SortOrder, mt.SortOrder) AS SortOrder, mt.IsActive,
(SELECT COUNT(1) FROM dbo.SceneMetricValues smv WHERE smv.MetricTypeID = mt.MetricTypeID) AS ValueCount 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 FROM dbo.SceneMetricTypes mt
LEFT JOIN dbo.ProjectTimelineMetricSettings ptms ON ptms.ProjectID = @ProjectID AND ptms.MetricTypeID = mt.MetricTypeID
WHERE mt.ProjectID IS NULL WHERE mt.ProjectID IS NULL
OR mt.ProjectID = @ProjectID OR mt.ProjectID = @ProjectID
OR EXISTS OR EXISTS
@ -74,7 +101,7 @@ BEGIN
INNER JOIN dbo.Books b ON b.BookID = c.BookID INNER JOIN dbo.Books b ON b.BookID = c.BookID
WHERE smv.MetricTypeID = mt.MetricTypeID AND b.ProjectID = @ProjectID 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, SELECT smv.SceneID, smv.MetricTypeID, mt.MetricName, mt.Description, mt.MinValue, mt.MaxValue,
mt.DefaultValue, mt.SortOrder, mt.IsActive, smv.Value, smv.Notes mt.DefaultValue, mt.SortOrder, mt.IsActive, smv.Value, smv.Notes
@ -91,7 +118,7 @@ BEGIN
FROM dbo.ProjectTimelineSettings FROM dbo.ProjectTimelineSettings
WHERE ProjectID = @ProjectID; 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 FROM dbo.ProjectTimelineMetricSettings ptms
INNER JOIN dbo.SceneMetricTypes mt ON mt.MetricTypeID = ptms.MetricTypeID INNER JOIN dbo.SceneMetricTypes mt ON mt.MetricTypeID = ptms.MetricTypeID
WHERE ptms.ProjectID = @ProjectID WHERE ptms.ProjectID = @ProjectID
@ -105,7 +132,8 @@ BEGIN
SELECT pl.PlotLineID, pl.ProjectID, pl.BookID, b.BookTitle, pl.PlotLineName, pl.PlotLineTypeID, 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, 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 FROM dbo.PlotLines pl
INNER JOIN dbo.PlotLineTypes plt ON plt.PlotLineTypeID = pl.PlotLineTypeID INNER JOIN dbo.PlotLineTypes plt ON plt.PlotLineTypeID = pl.PlotLineTypeID
LEFT JOIN dbo.Books b ON b.BookID = pl.BookID 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, 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, tt.TypeName AS ThreadTypeName, pt.ThreadStatusID, ts.StatusName AS ThreadStatusName, pt.Importance,
pt.Summary, pt.IntroducedSceneID, pt.PlannedResolutionSceneID, pt.ActualResolutionSceneID, 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 FROM dbo.PlotThreads pt
INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID
INNER JOIN dbo.ThreadTypes tt ON tt.ThreadTypeID = pt.ThreadTypeID 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, SELECT sa.StoryAssetID, sa.ProjectID, sa.AssetName, sa.AssetKindID, ak.KindName, sa.Description,
sa.Importance, sa.CurrentStateID, ast.StateName AS CurrentStateName, sa.CurrentLocationID, sa.Importance, sa.CurrentStateID, ast.StateName AS CurrentStateName, sa.CurrentLocationID,
l.LocationName AS CurrentLocationName, l.LocationName AS CurrentLocationPath, sa.IsResolved, 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 FROM dbo.StoryAssets sa
INNER JOIN dbo.AssetKinds ak ON ak.AssetKindID = sa.AssetKindID INNER JOIN dbo.AssetKinds ak ON ak.AssetKindID = sa.AssetKindID
LEFT JOIN dbo.AssetStates ast ON ast.AssetStateID = sa.CurrentStateID LEFT JOIN dbo.AssetStates ast ON ast.AssetStateID = sa.CurrentStateID
@ -145,9 +173,10 @@ BEGIN
WHERE sa.ProjectID = @ProjectID WHERE sa.ProjectID = @ProjectID
ORDER BY sa.Importance DESC, sa.AssetName; 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 ast.IsResolvedState, ast.IsActive
FROM dbo.AssetStates ast FROM dbo.AssetStates ast
LEFT JOIN dbo.AssetKinds ak ON ak.AssetKindID = ast.AssetKindID
WHERE ast.ProjectID IS NULL WHERE ast.ProjectID IS NULL
OR ast.ProjectID = @ProjectID OR ast.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM dbo.StoryAssets sa WHERE sa.ProjectID = @ProjectID AND sa.CurrentStateID = ast.AssetStateID) 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, SELECT CharacterID, ProjectID, CharacterName, ShortName, Sex, BirthDate, AgeAtSeriesStart,
AgeReferenceSceneID, Height, EyeColour, CharacterImportance, ShowInQuickAddBar, AgeReferenceSceneID, Height, EyeColour, CharacterImportance, ShowInQuickAddBar,
DefaultDescription, CreatedDate, UpdatedDate, IsArchived DefaultDescription, CreatedDate, UpdatedDate, IsArchived, ArchivedDate, ArchivedReason
FROM dbo.Characters FROM dbo.Characters
WHERE ProjectID = @ProjectID WHERE ProjectID = @ProjectID
ORDER BY CharacterImportance DESC, CharacterName; ORDER BY CharacterImportance DESC, CharacterName;
@ -281,7 +310,8 @@ BEGIN
rt.SortOrder AS RelationshipTypeSortOrder, cr.IsPermanent, cr.StartSceneID, cr.EndSceneID, rt.SortOrder AS RelationshipTypeSortOrder, cr.IsPermanent, cr.StartSceneID, cr.EndSceneID,
cr.IsKnownToReader, cr.Notes, cr.IsInitialRelationship, cr.InitialBookID, ib.BookTitle AS InitialBookTitle, cr.IsKnownToReader, cr.Notes, cr.IsInitialRelationship, cr.InitialBookID, ib.BookTitle AS InitialBookTitle,
cr.ReaderInitiallyKnows, cr.InitialRelationshipStateID, irs.StateName AS InitialRelationshipStateName, 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 FROM dbo.CharacterRelationships cr
INNER JOIN dbo.Characters a ON a.CharacterID = cr.CharacterAID INNER JOIN dbo.Characters a ON a.CharacterID = cr.CharacterAID
INNER JOIN dbo.Characters bch ON bch.CharacterID = cr.CharacterBID 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, SELECT l.LocationID, l.ProjectID, l.ParentLocationID, parent.LocationName AS ParentLocationName,
l.LocationName, l.LocationTypeID, lt.TypeName AS LocationTypeName, l.Description, l.LocationName, l.LocationTypeID, lt.TypeName AS LocationTypeName, l.Description,
l.LocationName AS LocationPath, CAST(0 AS int) AS Depth, l.ShowInQuickAddBar, 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 FROM dbo.Locations l
LEFT JOIN dbo.Locations parent ON parent.LocationID = l.ParentLocationID LEFT JOIN dbo.Locations parent ON parent.LocationID = l.ParentLocationID
LEFT JOIN dbo.LocationTypes lt ON lt.LocationTypeID = l.LocationTypeID LEFT JOIN dbo.LocationTypes lt ON lt.LocationTypeID = l.LocationTypeID
@ -426,7 +456,8 @@ BEGIN
ORDER BY b.SortOrder, c.SortOrder; ORDER BY b.SortOrder, c.SortOrder;
SELECT sc.ScenarioID, sc.ProjectID, sc.BookID, sc.ScenarioName, sc.Description, sc.BaseCreatedDate, 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.ScenarioSceneOrder sso WHERE sso.ScenarioID = sc.ScenarioID) AS SceneCount,
(SELECT COUNT(1) FROM dbo.ScenarioWarnings sw WHERE sw.ScenarioID = sc.ScenarioID) AS WarningCount (SELECT COUNT(1) FROM dbo.ScenarioWarnings sw WHERE sw.ScenarioID = sc.ScenarioID) AS WarningCount
FROM dbo.Scenarios sc FROM dbo.Scenarios sc
@ -454,5 +485,34 @@ BEGIN
INNER JOIN dbo.Scenarios sc ON sc.ScenarioID = sw.ScenarioID INNER JOIN dbo.Scenarios sc ON sc.ScenarioID = sw.ScenarioID
WHERE sc.ProjectID = @ProjectID WHERE sc.ProjectID = @ProjectID
ORDER BY sw.CreatedDate DESC, sw.ScenarioWarningID; 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; END;
GO GO

View File

@ -95,3 +95,41 @@ BEGIN
WHERE rp.ProjectRestorePointID = @ProjectRestorePointID; WHERE rp.ProjectRestorePointID = @ProjectRestorePointID;
END; END;
GO 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

View File

@ -9,8 +9,8 @@ BEGIN
( (
ProjectRestoreHistoryID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProjectRestoreHistory PRIMARY KEY, ProjectRestoreHistoryID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProjectRestoreHistory PRIMARY KEY,
ProjectID int NOT NULL, ProjectID int NOT NULL,
SourceRestorePointID int NOT NULL, SourceRestorePointID int NULL,
SafetyRestorePointID int NOT NULL, SafetyRestorePointID int NULL,
RestoredDateUtc datetime2 NOT NULL CONSTRAINT DF_ProjectRestoreHistory_RestoredDateUtc DEFAULT SYSUTCDATETIME(), RestoredDateUtc datetime2 NOT NULL CONSTRAINT DF_ProjectRestoreHistory_RestoredDateUtc DEFAULT SYSUTCDATETIME(),
PerformedBy nvarchar(100) NULL, PerformedBy nvarchar(100) NULL,
Notes nvarchar(max) NULL, Notes nvarchar(max) NULL,
@ -21,6 +21,28 @@ BEGIN
END; END;
GO 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')) 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); CREATE INDEX IX_ProjectRestoreHistory_ProjectDate ON dbo.ProjectRestoreHistory(ProjectID, RestoredDateUtc DESC);
GO GO
@ -117,8 +139,8 @@ GO
CREATE OR ALTER PROCEDURE dbo.ProjectRestore_ApplyFromJson CREATE OR ALTER PROCEDURE dbo.ProjectRestore_ApplyFromJson
@ProjectID int, @ProjectID int,
@SourceRestorePointID int, @SourceRestorePointID int = NULL,
@SafetyRestorePointID int, @SafetyRestorePointID int = NULL,
@JsonData nvarchar(max) @JsonData nvarchar(max)
AS AS
BEGIN BEGIN
@ -231,7 +253,9 @@ BEGIN
Description = src.Description, Description = src.Description,
CreatedDate = src.CreatedDate, CreatedDate = src.CreatedDate,
UpdatedDate = SYSUTCDATETIME(), UpdatedDate = SYSUTCDATETIME(),
IsArchived = src.IsArchived IsArchived = src.IsArchived,
ArchivedDate = src.ArchivedDate,
ArchivedReason = src.ArchivedReason
FROM dbo.Projects p FROM dbo.Projects p
CROSS APPLY OPENJSON(@JsonData, '$.Project') CROSS APPLY OPENJSON(@JsonData, '$.Project')
WITH WITH
@ -240,7 +264,9 @@ BEGIN
ProjectName nvarchar(200) '$.ProjectName', ProjectName nvarchar(200) '$.ProjectName',
Description nvarchar(max) '$.Description', Description nvarchar(max) '$.Description',
CreatedDate datetime2 '$.CreatedDate', CreatedDate datetime2 '$.CreatedDate',
IsArchived bit '$.IsArchived' IsArchived bit '$.IsArchived',
ArchivedDate datetime2 '$.ArchivedDate',
ArchivedReason nvarchar(1000) '$.ArchivedReason'
) src ) src
WHERE p.ProjectID = @ProjectID AND src.ProjectID = @ProjectID; WHERE p.ProjectID = @ProjectID AND src.ProjectID = @ProjectID;
@ -298,9 +324,144 @@ BEGIN
CLOSE EnableCursor; CLOSE EnableCursor;
DEALLOCATE EnableCursor; DEALLOCATE EnableCursor;
IF @SourceRestorePointID IS NOT NULL AND @SafetyRestorePointID IS NOT NULL
BEGIN
INSERT INTO dbo.ProjectRestoreHistory (ProjectID, SourceRestorePointID, SafetyRestorePointID, PerformedBy, Notes) INSERT INTO dbo.ProjectRestoreHistory (ProjectID, SourceRestorePointID, SafetyRestorePointID, PerformedBy, Notes)
VALUES (@ProjectID, @SourceRestorePointID, @SafetyRestorePointID, SUSER_SNAME(), N'Project restored from saved restore point.'); VALUES (@ProjectID, @SourceRestorePointID, @SafetyRestorePointID, SUSER_SNAME(), N'Project restored from saved restore point.');
END;
COMMIT TRANSACTION; COMMIT TRANSACTION;
END; END;
GO 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

View File

@ -8,7 +8,7 @@
<div> <div>
<p class="eyebrow">Tools</p> <p class="eyebrow">Tools</p>
<h1>JSON Import <help-icon key="imports.overview" /></h1> <h1>JSON Import <help-icon key="imports.overview" /></h1>
<p class="lead-text">Paste or upload a Phase 1G package, preview it, dry-run if needed, then import a new project.</p> <p class="lead-text">Paste or upload a legacy JSON package or a PlotWeaver backup file, preview it, dry-run if needed, then import a new project.</p>
</div> </div>
<div class="button-row"> <div class="button-row">
<a class="btn btn-outline-primary" asp-action="Sample">Download sample JSON</a> <a class="btn btn-outline-primary" asp-action="Sample">Download sample JSON</a>
@ -39,13 +39,13 @@
<form asp-action="Preview" method="post" enctype="multipart/form-data"> <form asp-action="Preview" method="post" enctype="multipart/form-data">
<div class="row g-3"> <div class="row g-3">
<div class="col-12"> <div class="col-12">
<label asp-for="JsonFile" class="form-label">Upload JSON file</label> <label asp-for="JsonFile" class="form-label">Upload JSON or PlotWeaver backup file</label>
<input asp-for="JsonFile" class="form-control" type="file" accept=".json,application/json" /> <input asp-for="JsonFile" class="form-control" type="file" accept=".json,.plotweaver,application/json" />
<div class="form-text">Uploading a file replaces the pasted text for this preview.</div> <div class="form-text">Uploading a file replaces the pasted text for this preview.</div>
<div class="form-text" data-upload-status></div> <div class="form-text" data-upload-status></div>
</div> </div>
<div class="col-12"> <div class="col-12">
<label asp-for="JsonText" class="form-label">JSON package</label> <label asp-for="JsonText" class="form-label">JSON package or backup content</label>
<textarea asp-for="JsonText" class="form-control import-json-input" rows="22" spellcheck="false"></textarea> <textarea asp-for="JsonText" class="form-control import-json-input" rows="22" spellcheck="false"></textarea>
</div> </div>
</div> </div>

View File

@ -2,6 +2,8 @@
@{ @{
ViewData["Title"] = "Restore Points"; ViewData["Title"] = "Restore Points";
var defaultRestorePointName = $"Manual restore point - {DateTime.Now:yyyy-MM-dd HH:mm}"; 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 { @functions {
@ -50,18 +52,22 @@
<h2>Create restore point</h2> <h2>Create restore point</h2>
</div> </div>
</div> </div>
<form asp-action="Create" method="post" class="row g-3"> @if (restorePointLimitReached)
{
<div class="alert alert-warning">@restorePointLimitMessage</div>
}
<form asp-action="Create" method="post" class="row g-3" data-confirm-message="Create restore point?&#10;&#10;This will save the current state of this project as a restore point. You can return to it later if you need to undo major changes." data-confirm-button-text="Create Restore Point" data-confirm-button-class="btn-primary">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" /> <input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<div class="col-md-5"> <div class="col-md-5">
<label class="form-label" for="restorePointName">Name</label> <label class="form-label" for="restorePointName">Name</label>
<input class="form-control" id="restorePointName" name="restorePointName" maxlength="255" placeholder="@defaultRestorePointName" /> <input class="form-control" id="restorePointName" name="restorePointName" maxlength="255" placeholder="@defaultRestorePointName" disabled="@restorePointLimitReached" />
</div> </div>
<div class="col-md-5"> <div class="col-md-5">
<label class="form-label" for="notes">Notes</label> <label class="form-label" for="notes">Notes</label>
<input class="form-control" id="notes" name="notes" maxlength="1000" placeholder="Optional reason or reminder" /> <input class="form-control" id="notes" name="notes" maxlength="1000" placeholder="Optional reason or reminder" disabled="@restorePointLimitReached" />
</div> </div>
<div class="col-md-2 d-flex align-items-end"> <div class="col-md-2 d-flex align-items-end">
<button class="btn btn-primary w-100" type="submit">Create Restore Point</button> <button class="btn btn-primary w-100" type="submit" disabled="@restorePointLimitReached">Create Restore Point</button>
</div> </div>
</form> </form>
</section> </section>
@ -98,6 +104,7 @@
<td class="text-end"> <td class="text-end">
<a class="btn btn-outline-secondary btn-sm" asp-action="Download" asp-route-projectId="@Model.Project.ProjectID" asp-route-id="@restorePoint.ProjectRestorePointID">Download</a> <a class="btn btn-outline-secondary btn-sm" asp-action="Download" asp-route-projectId="@Model.Project.ProjectID" asp-route-id="@restorePoint.ProjectRestorePointID">Download</a>
<button class="btn btn-outline-danger btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#restorePointModal-@restorePoint.ProjectRestorePointID">Restore</button> <button class="btn btn-outline-danger btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#restorePointModal-@restorePoint.ProjectRestorePointID">Restore</button>
<button class="btn btn-outline-danger btn-sm" type="button" data-bs-toggle="modal" data-bs-target="#deleteRestorePointModal-@restorePoint.ProjectRestorePointID">Delete</button>
</td> </td>
</tr> </tr>
} }
@ -130,6 +137,29 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal fade" id="deleteRestorePointModal-@restorePoint.ProjectRestorePointID" tabindex="-1" aria-labelledby="deleteRestorePointModalLabel-@restorePoint.ProjectRestorePointID" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title fs-5" id="deleteRestorePointModalLabel-@restorePoint.ProjectRestorePointID">Delete Restore Point</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>This will permanently delete this restore point. You will not be able to restore from it after deletion.</p>
<p class="mb-0">You can download the restore point first if you want to keep a local backup copy.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<form asp-action="Delete" method="post" data-confirm-skip="true">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<input type="hidden" name="id" value="@restorePoint.ProjectRestorePointID" />
<button class="btn btn-danger" type="submit">Delete Restore Point</button>
</form>
</div>
</div>
</div>
</div>
} }
} }
</section> </section>