diff --git a/PlotLine/Controllers/ProjectRestorePointsController.cs b/PlotLine/Controllers/ProjectRestorePointsController.cs new file mode 100644 index 0000000..9352600 --- /dev/null +++ b/PlotLine/Controllers/ProjectRestorePointsController.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Mvc; +using PlotLine.Services; + +namespace PlotLine.Controllers; + +public sealed class ProjectRestorePointsController(IProjectRestorePointService restorePoints, ILogger logger) : Controller +{ + public async Task Index(int projectId) + { + var model = await restorePoints.GetAsync(projectId); + return model is null ? NotFound() : View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Create(int projectId, string? restorePointName, string? notes) + { + try + { + 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." + : "Restore point created."; + } + catch (Exception ex) + { + logger.LogError(ex, "Manual restore point creation failed for project {ProjectID}.", projectId); + TempData["ProjectRestorePointMessage"] = "Restore point could not be created. Please try again, or check the application logs if the problem continues."; + } + + return RedirectToAction(nameof(Index), new { projectId }); + } + + public async Task Download(int projectId, int id) + { + try + { + var download = await restorePoints.DownloadAsync(id, projectId); + if (download is null) + { + TempData["ProjectRestorePointMessage"] = "Restore point could not be found."; + return RedirectToAction(nameof(Index), new { projectId }); + } + + return File(System.Text.Encoding.UTF8.GetBytes(download.Json), download.ContentType, download.FileName); + } + catch (Exception ex) + { + logger.LogError(ex, "Restore point download failed for restore point {ProjectRestorePointID} in project {ProjectID}.", id, projectId); + TempData["ProjectRestorePointMessage"] = "Restore point could not be downloaded. Please try again, or check the application logs if the problem continues."; + return RedirectToAction(nameof(Index), new { projectId }); + } + } +} diff --git a/PlotLine/Data/ProjectRestorePointRepository.cs b/PlotLine/Data/ProjectRestorePointRepository.cs new file mode 100644 index 0000000..4e13f7a --- /dev/null +++ b/PlotLine/Data/ProjectRestorePointRepository.cs @@ -0,0 +1,50 @@ +using System.Data; +using Dapper; +using PlotLine.Models; + +namespace PlotLine.Data; + +public interface IProjectRestorePointRepository +{ + Task> ListAsync(int projectId); + Task GetAsync(int projectRestorePointId, int projectId); + Task CreateAsync(int projectId, string restorePointName, string restorePointType, string jsonData, string? notes); +} + +public sealed class ProjectRestorePointRepository(ISqlConnectionFactory connectionFactory) : IProjectRestorePointRepository +{ + public async Task> ListAsync(int projectId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.ProjectRestorePoint_List", + new { ProjectID = projectId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task GetAsync(int projectRestorePointId, int projectId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleOrDefaultAsync( + "dbo.ProjectRestorePoint_Get", + new { ProjectRestorePointID = projectRestorePointId, ProjectID = projectId }, + commandType: CommandType.StoredProcedure); + } + + public async Task CreateAsync(int projectId, string restorePointName, string restorePointType, string jsonData, string? notes) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.ProjectRestorePoint_Create", + new + { + ProjectID = projectId, + RestorePointName = restorePointName, + RestorePointType = restorePointType, + JsonData = jsonData, + Notes = notes + }, + commandType: CommandType.StoredProcedure); + } +} diff --git a/PlotLine/Models/ProjectRestorePointModels.cs b/PlotLine/Models/ProjectRestorePointModels.cs new file mode 100644 index 0000000..8019f84 --- /dev/null +++ b/PlotLine/Models/ProjectRestorePointModels.cs @@ -0,0 +1,21 @@ +namespace PlotLine.Models; + +public sealed class ProjectRestorePoint +{ + public int ProjectRestorePointID { get; set; } + public int ProjectID { get; set; } + public string ProjectName { get; set; } = string.Empty; + public string RestorePointName { get; set; } = string.Empty; + public string RestorePointType { get; set; } = "Manual"; + public DateTime CreatedDateUtc { get; set; } + public string? JsonData { get; set; } + public long JsonSizeBytes { get; set; } + public string? Notes { get; set; } +} + +public sealed class ProjectRestorePointDownload +{ + public string FileName { get; init; } = string.Empty; + public string ContentType { get; init; } = "application/json"; + public string Json { get; init; } = string.Empty; +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index d469edd..d86e712 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -38,6 +38,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -60,6 +61,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/ProjectRestorePointService.cs b/PlotLine/Services/ProjectRestorePointService.cs new file mode 100644 index 0000000..abb0aca --- /dev/null +++ b/PlotLine/Services/ProjectRestorePointService.cs @@ -0,0 +1,97 @@ +using PlotLine.Data; +using PlotLine.Models; +using PlotLine.ViewModels; + +namespace PlotLine.Services; + +public interface IProjectRestorePointService +{ + Task GetAsync(int projectId); + Task CreateManualAsync(int projectId, string? restorePointName, string? notes); + Task DownloadAsync(int projectRestorePointId, int projectId); +} + +public sealed class ProjectRestorePointService( + IProjectRepository projects, + IProjectExportService projectExports, + IProjectRestorePointRepository restorePoints, + ILogger logger) : IProjectRestorePointService +{ + private const string ManualRestorePointType = "Manual"; + + public async Task GetAsync(int projectId) + { + var project = await projects.GetAsync(projectId); + if (project is null) + { + return null; + } + + return new ProjectRestorePointListViewModel + { + Project = project, + RestorePoints = await restorePoints.ListAsync(projectId) + }; + } + + public async Task CreateManualAsync(int projectId, string? restorePointName, string? notes) + { + var export = await projectExports.ExportAsync(projectId); + if (export is null) + { + logger.LogWarning("Manual restore point requested for missing project {ProjectID}.", projectId); + return null; + } + + var name = string.IsNullOrWhiteSpace(restorePointName) + ? $"Manual restore point - {DateTime.Now:yyyy-MM-dd HH:mm}" + : restorePointName.Trim(); + + var created = await restorePoints.CreateAsync(projectId, name, ManualRestorePointType, export.Json, Clean(notes)); + logger.LogInformation("Manual restore point {ProjectRestorePointID} created for project {ProjectID} with {JsonSizeBytes} JSON bytes.", + created.ProjectRestorePointID, + projectId, + created.JsonSizeBytes); + + return created; + } + + public async Task DownloadAsync(int projectRestorePointId, int projectId) + { + var restorePoint = await restorePoints.GetAsync(projectRestorePointId, projectId); + if (restorePoint?.JsonData is null) + { + logger.LogWarning("Restore point download requested for missing restore point {ProjectRestorePointID} in project {ProjectID}.", projectRestorePointId, projectId); + return null; + } + + return new ProjectRestorePointDownload + { + FileName = BuildFileName(restorePoint.ProjectName, restorePoint.CreatedDateUtc), + ContentType = "application/json", + Json = restorePoint.JsonData + }; + } + + private static string? Clean(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static string BuildFileName(string projectName, DateTime createdDateUtc) + { + var safeTitle = new string(projectName + .Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c) + .ToArray()) + .Trim(); + + safeTitle = string.Join("_", safeTitle.Split(' ', StringSplitOptions.RemoveEmptyEntries)); + + if (string.IsNullOrWhiteSpace(safeTitle)) + { + safeTitle = "PlotWeaver_Project"; + } + + return $"{safeTitle}_RestorePoint_{createdDateUtc:yyyy-MM-dd_HHmm}.plotweaver"; + } +} diff --git a/PlotLine/Sql/035_ProjectRestorePoints.sql b/PlotLine/Sql/035_ProjectRestorePoints.sql new file mode 100644 index 0000000..8e2b277 --- /dev/null +++ b/PlotLine/Sql/035_ProjectRestorePoints.sql @@ -0,0 +1,97 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.ProjectRestorePoints', N'U') IS NULL +BEGIN + CREATE TABLE dbo.ProjectRestorePoints + ( + ProjectRestorePointID int IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProjectRestorePoints PRIMARY KEY, + ProjectID int NOT NULL, + RestorePointName nvarchar(255) NOT NULL, + RestorePointType nvarchar(50) NOT NULL, + CreatedDateUtc datetime2 NOT NULL CONSTRAINT DF_ProjectRestorePoints_CreatedDateUtc DEFAULT SYSUTCDATETIME(), + JsonData nvarchar(max) NOT NULL, + JsonSizeBytes bigint NOT NULL, + Notes nvarchar(max) NULL, + CONSTRAINT FK_ProjectRestorePoints_Projects FOREIGN KEY (ProjectID) REFERENCES dbo.Projects(ProjectID) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_ProjectRestorePoints_ProjectCreated' AND object_id = OBJECT_ID(N'dbo.ProjectRestorePoints')) + CREATE INDEX IX_ProjectRestorePoints_ProjectCreated ON dbo.ProjectRestorePoints(ProjectID, CreatedDateUtc DESC); +GO + +CREATE OR ALTER PROCEDURE dbo.ProjectRestorePoint_List + @ProjectID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT rp.ProjectRestorePointID, rp.ProjectID, p.ProjectName, rp.RestorePointName, rp.RestorePointType, + rp.CreatedDateUtc, CAST(NULL AS nvarchar(max)) AS JsonData, rp.JsonSizeBytes, rp.Notes + FROM dbo.ProjectRestorePoints rp + INNER JOIN dbo.Projects p ON p.ProjectID = rp.ProjectID + WHERE rp.ProjectID = @ProjectID + ORDER BY rp.CreatedDateUtc DESC, rp.ProjectRestorePointID DESC; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ProjectRestorePoint_Get + @ProjectRestorePointID int, + @ProjectID int +AS +BEGIN + SET NOCOUNT ON; + + SELECT rp.ProjectRestorePointID, rp.ProjectID, p.ProjectName, rp.RestorePointName, rp.RestorePointType, + rp.CreatedDateUtc, rp.JsonData, rp.JsonSizeBytes, rp.Notes + FROM dbo.ProjectRestorePoints rp + INNER JOIN dbo.Projects p ON p.ProjectID = rp.ProjectID + WHERE rp.ProjectRestorePointID = @ProjectRestorePointID + AND rp.ProjectID = @ProjectID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ProjectRestorePoint_Create + @ProjectID int, + @RestorePointName nvarchar(255), + @RestorePointType nvarchar(50), + @JsonData nvarchar(max), + @Notes nvarchar(max) = NULL +AS +BEGIN + SET NOCOUNT ON; + + INSERT INTO dbo.ProjectRestorePoints + ( + ProjectID, + RestorePointName, + RestorePointType, + CreatedDateUtc, + JsonData, + JsonSizeBytes, + Notes + ) + VALUES + ( + @ProjectID, + @RestorePointName, + @RestorePointType, + SYSUTCDATETIME(), + @JsonData, + DATALENGTH(@JsonData), + @Notes + ); + + DECLARE @ProjectRestorePointID int = SCOPE_IDENTITY(); + + SELECT rp.ProjectRestorePointID, rp.ProjectID, p.ProjectName, rp.RestorePointName, rp.RestorePointType, + rp.CreatedDateUtc, CAST(NULL AS nvarchar(max)) AS JsonData, rp.JsonSizeBytes, rp.Notes + FROM dbo.ProjectRestorePoints rp + INNER JOIN dbo.Projects p ON p.ProjectID = rp.ProjectID + WHERE rp.ProjectRestorePointID = @ProjectRestorePointID; +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index f8432e6..51d8a2d 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -455,6 +455,12 @@ public sealed class ProjectSceneMetricsViewModel public bool HasEnabledMetrics => Metrics.Any(x => x.IsEnabledForProject && x.IsActive); } +public sealed class ProjectRestorePointListViewModel +{ + public Project Project { get; init; } = new(); + public IReadOnlyList RestorePoints { get; init; } = []; +} + public sealed class SceneMetricTypeEditViewModel { public int MetricTypeID { get; set; } diff --git a/PlotLine/Views/ProjectRestorePoints/Index.cshtml b/PlotLine/Views/ProjectRestorePoints/Index.cshtml new file mode 100644 index 0000000..0b4f53d --- /dev/null +++ b/PlotLine/Views/ProjectRestorePoints/Index.cshtml @@ -0,0 +1,107 @@ +@model ProjectRestorePointListViewModel +@{ + ViewData["Title"] = "Restore Points"; + var defaultRestorePointName = $"Manual restore point - {DateTime.Now:yyyy-MM-dd HH:mm}"; +} + +@functions { + private static string FormatSize(long bytes) + { + if (bytes >= 1024 * 1024) + { + return $"{bytes / 1024d / 1024d:0.0} MB"; + } + + if (bytes >= 1024) + { + return $"{bytes / 1024d:0.0} KB"; + } + + return $"{bytes} bytes"; + } +} + + + +
+
+

Project backup

+

Restore Points

+

Create manual database-saved snapshots of the project export JSON.

+
+ +
+ +@if (TempData["ProjectRestorePointMessage"] is string message) +{ +
@message
+} + +
+
+
+

Manual snapshot

+

Create restore point

+
+
+
+ +
+ + +
+
+ + +
+
+ +
+
+
+ +
+

Saved restore points

+ @if (!Model.RestorePoints.Any()) + { +

No restore points yet.

+ } + else + { +
+ + + + + + + + + + + + + @foreach (var restorePoint in Model.RestorePoints) + { + + + + + + + + + } + +
NameTypeCreatedSizeNotes
@restorePoint.RestorePointName@restorePoint.RestorePointType@restorePoint.CreatedDateUtc.ToLocalTime().ToString("dd MMM yyyy HH:mm")@FormatSize(restorePoint.JsonSizeBytes)@restorePoint.Notes + Download +
+
+ } +
diff --git a/PlotLine/Views/Projects/Details.cshtml b/PlotLine/Views/Projects/Details.cshtml index c528329..7a5423e 100644 --- a/PlotLine/Views/Projects/Details.cshtml +++ b/PlotLine/Views/Projects/Details.cshtml @@ -62,6 +62,7 @@ Phase 1 Checklist Archived Items Download Project Backup + Restore Points Add book