Rrestore point page created

This commit is contained in:
Nick Beckley 2026-06-05 16:15:49 +01:00
parent fbc07a00e5
commit aebaa00371
9 changed files with 435 additions and 0 deletions

View File

@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Mvc;
using PlotLine.Services;
namespace PlotLine.Controllers;
public sealed class ProjectRestorePointsController(IProjectRestorePointService restorePoints, ILogger<ProjectRestorePointsController> logger) : Controller
{
public async Task<IActionResult> Index(int projectId)
{
var model = await restorePoints.GetAsync(projectId);
return model is null ? NotFound() : View(model);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> 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 });
}
}
}

View File

@ -0,0 +1,50 @@
using System.Data;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IProjectRestorePointRepository
{
Task<IReadOnlyList<ProjectRestorePoint>> ListAsync(int projectId);
Task<ProjectRestorePoint?> GetAsync(int projectRestorePointId, int projectId);
Task<ProjectRestorePoint> CreateAsync(int projectId, string restorePointName, string restorePointType, string jsonData, string? notes);
}
public sealed class ProjectRestorePointRepository(ISqlConnectionFactory connectionFactory) : IProjectRestorePointRepository
{
public async Task<IReadOnlyList<ProjectRestorePoint>> ListAsync(int projectId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<ProjectRestorePoint>(
"dbo.ProjectRestorePoint_List",
new { ProjectID = projectId },
commandType: CommandType.StoredProcedure);
return rows.ToList();
}
public async Task<ProjectRestorePoint?> GetAsync(int projectRestorePointId, int projectId)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<ProjectRestorePoint>(
"dbo.ProjectRestorePoint_Get",
new { ProjectRestorePointID = projectRestorePointId, ProjectID = projectId },
commandType: CommandType.StoredProcedure);
}
public async Task<ProjectRestorePoint> CreateAsync(int projectId, string restorePointName, string restorePointType, string jsonData, string? notes)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<ProjectRestorePoint>(
"dbo.ProjectRestorePoint_Create",
new
{
ProjectID = projectId,
RestorePointName = restorePointName,
RestorePointType = restorePointType,
JsonData = jsonData,
Notes = notes
},
commandType: CommandType.StoredProcedure);
}
}

View File

@ -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;
}

View File

@ -38,6 +38,7 @@ public class Program
builder.Services.AddScoped<IWriterWorkspaceRepository, WriterWorkspaceRepository>();
builder.Services.AddScoped<IExportRepository, ExportRepository>();
builder.Services.AddScoped<IProjectBackupRepository, ProjectBackupRepository>();
builder.Services.AddScoped<IProjectRestorePointRepository, ProjectRestorePointRepository>();
builder.Services.AddScoped<IDynamicsRepository, DynamicsRepository>();
builder.Services.AddScoped<IScenarioRepository, ScenarioRepository>();
builder.Services.AddScoped<IArchiveRepository, ArchiveRepository>();
@ -60,6 +61,7 @@ public class Program
builder.Services.AddScoped<IWriterWorkspaceService, WriterWorkspaceService>();
builder.Services.AddScoped<IExportService, ExportService>();
builder.Services.AddScoped<IProjectExportService, ProjectExportService>();
builder.Services.AddScoped<IProjectRestorePointService, ProjectRestorePointService>();
builder.Services.AddScoped<IDynamicsService, DynamicsService>();
builder.Services.AddScoped<IScenarioService, ScenarioService>();
builder.Services.AddScoped<IArchiveService, ArchiveService>();

View File

@ -0,0 +1,97 @@
using PlotLine.Data;
using PlotLine.Models;
using PlotLine.ViewModels;
namespace PlotLine.Services;
public interface IProjectRestorePointService
{
Task<ProjectRestorePointListViewModel?> GetAsync(int projectId);
Task<ProjectRestorePoint?> CreateManualAsync(int projectId, string? restorePointName, string? notes);
Task<ProjectRestorePointDownload?> DownloadAsync(int projectRestorePointId, int projectId);
}
public sealed class ProjectRestorePointService(
IProjectRepository projects,
IProjectExportService projectExports,
IProjectRestorePointRepository restorePoints,
ILogger<ProjectRestorePointService> logger) : IProjectRestorePointService
{
private const string ManualRestorePointType = "Manual";
public async Task<ProjectRestorePointListViewModel?> 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<ProjectRestorePoint?> 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<ProjectRestorePointDownload?> 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";
}
}

View File

@ -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

View File

@ -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<ProjectRestorePoint> RestorePoints { get; init; } = [];
}
public sealed class SceneMetricTypeEditViewModel
{
public int MetricTypeID { get; set; }

View File

@ -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";
}
}
<nav class="breadcrumb-trail">
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
<span>Restore Points</span>
</nav>
<div class="page-heading compact">
<div>
<p class="eyebrow">Project backup</p>
<h1>Restore Points</h1>
<p class="lead-text">Create manual database-saved snapshots of the project export JSON.</p>
</div>
<div class="button-row">
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">Back to project</a>
<a class="btn btn-outline-secondary" asp-controller="Exports" asp-action="ProjectBackup" asp-route-projectId="@Model.Project.ProjectID">Download Project Backup</a>
</div>
</div>
@if (TempData["ProjectRestorePointMessage"] is string message)
{
<div class="alert alert-info">@message</div>
}
<section class="story-bible-section">
<div class="story-bible-section-heading">
<div>
<p class="eyebrow">Manual snapshot</p>
<h2>Create restore point</h2>
</div>
</div>
<form asp-action="Create" method="post" class="row g-3">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<div class="col-md-5">
<label class="form-label" for="restorePointName">Name</label>
<input class="form-control" id="restorePointName" name="restorePointName" maxlength="255" placeholder="@defaultRestorePointName" />
</div>
<div class="col-md-5">
<label class="form-label" for="notes">Notes</label>
<input class="form-control" id="notes" name="notes" maxlength="1000" placeholder="Optional reason or reminder" />
</div>
<div class="col-md-2 d-flex align-items-end">
<button class="btn btn-primary w-100" type="submit">Create Restore Point</button>
</div>
</form>
</section>
<section class="list-section">
<h2>Saved restore points</h2>
@if (!Model.RestorePoints.Any())
{
<p class="muted">No restore points yet.</p>
}
else
{
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Created</th>
<th>Size</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var restorePoint in Model.RestorePoints)
{
<tr>
<td>@restorePoint.RestorePointName</td>
<td><span class="status-pill">@restorePoint.RestorePointType</span></td>
<td>@restorePoint.CreatedDateUtc.ToLocalTime().ToString("dd MMM yyyy HH:mm")</td>
<td>@FormatSize(restorePoint.JsonSizeBytes)</td>
<td>@restorePoint.Notes</td>
<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>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>

View File

@ -62,6 +62,7 @@
<a class="btn btn-outline-secondary" asp-controller="Acceptance" asp-action="Phase1" asp-route-projectId="@Model.Project.ProjectID">Phase 1 Checklist</a>
<a class="btn btn-outline-secondary" asp-controller="Archives" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Archived Items</a>
<a class="btn btn-outline-secondary" asp-controller="Exports" asp-action="ProjectBackup" asp-route-projectId="@Model.Project.ProjectID">Download Project Backup</a>
<a class="btn btn-outline-secondary" asp-controller="ProjectRestorePoints" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Restore Points</a>
<a class="btn btn-primary" asp-controller="Books" asp-action="Create" asp-route-projectId="@Model.Project.ProjectID">Add book</a>
<form asp-action="Archive" method="post" class="archive-button-form" data-confirm-message="Archive this project?&#10;&#10;This will hide it from active lists and timelines, but the data will be kept and can be restored later.">
<input type="hidden" name="id" value="@Model.Project.ProjectID" />