Importer Phase 1A
This commit is contained in:
parent
264f63b105
commit
8dd4d4d5a3
37
PlotLine/Controllers/ImportsController.cs
Normal file
37
PlotLine/Controllers/ImportsController.cs
Normal file
@ -0,0 +1,37 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PlotLine.Services;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
namespace PlotLine.Controllers;
|
||||
|
||||
public sealed class ImportsController(IImportService imports) : Controller
|
||||
{
|
||||
public IActionResult Index() => View(new ImportIndexViewModel
|
||||
{
|
||||
JsonText = ImportIndexViewModel.SampleJson
|
||||
});
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Preview(ImportIndexViewModel model)
|
||||
{
|
||||
model = await imports.PreviewAsync(model);
|
||||
return View("Index", model);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Import(ImportIndexViewModel model)
|
||||
{
|
||||
var result = await imports.ImportAsync(model);
|
||||
if (result.Succeeded && result.ProjectID.HasValue)
|
||||
{
|
||||
TempData["ImportMessage"] = result.Message;
|
||||
return RedirectToAction("Details", "Projects", new { id = result.ProjectID.Value });
|
||||
}
|
||||
|
||||
model.Preview ??= (await imports.PreviewAsync(model)).Preview;
|
||||
model.StatusMessage = result.Message;
|
||||
return View("Index", model);
|
||||
}
|
||||
}
|
||||
197
PlotLine/Data/ImportRepository.cs
Normal file
197
PlotLine/Data/ImportRepository.cs
Normal file
@ -0,0 +1,197 @@
|
||||
using System.Data;
|
||||
using System.Text;
|
||||
using Dapper;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Data;
|
||||
|
||||
public interface IImportRepository
|
||||
{
|
||||
Task<bool> ProjectTitleExistsAsync(string projectTitle);
|
||||
Task<ImportResult> ImportAsync(PlotLineImportPackage package);
|
||||
}
|
||||
|
||||
public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : IImportRepository
|
||||
{
|
||||
private const string ImportMarker = "Imported via JSON Import Phase 1A";
|
||||
|
||||
public async Task<bool> ProjectTitleExistsAsync(string projectTitle)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var count = await connection.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(1)
|
||||
FROM dbo.Projects
|
||||
WHERE IsArchived = 0 AND ProjectName = @ProjectName;
|
||||
""",
|
||||
new { ProjectName = projectTitle });
|
||||
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
public async Task<ImportResult> ImportAsync(PlotLineImportPackage package)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
connection.Open();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
|
||||
try
|
||||
{
|
||||
var lookupIds = await GetLookupIdsAsync(connection, transaction);
|
||||
var projectId = await connection.QuerySingleAsync<int>(
|
||||
"dbo.Project_Save",
|
||||
new
|
||||
{
|
||||
ProjectID = 0,
|
||||
ProjectName = Clean(package.Project.Title),
|
||||
Description = CombineText(package.Project.Description, package.Project.Notes, ImportMarker)
|
||||
},
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
foreach (var book in package.Books.OrderBy(x => x.SeriesOrder))
|
||||
{
|
||||
var bookId = await connection.QuerySingleAsync<int>(
|
||||
"dbo.Book_Save",
|
||||
new
|
||||
{
|
||||
BookID = 0,
|
||||
ProjectID = projectId,
|
||||
BookTitle = Clean(FormatBookTitle(book)),
|
||||
BookNumber = book.SeriesOrder,
|
||||
Description = CombineText(
|
||||
book.Description,
|
||||
book.DependsOnPreviousBook ? "Depends on previous book." : null,
|
||||
book.Notes,
|
||||
ImportMarker)
|
||||
},
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
foreach (var chapter in book.Chapters.OrderBy(x => x.Order))
|
||||
{
|
||||
var chapterId = await connection.QuerySingleAsync<int>(
|
||||
"dbo.Chapter_Save",
|
||||
new
|
||||
{
|
||||
ChapterID = 0,
|
||||
BookID = bookId,
|
||||
chapter.ChapterNumber,
|
||||
ChapterTitle = Clean(chapter.Title),
|
||||
Summary = CombineText(chapter.Summary, chapter.Notes, ImportMarker),
|
||||
RevisionStatusID = lookupIds.RevisionStatusID
|
||||
},
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
|
||||
foreach (var scene in chapter.Scenes.OrderBy(x => x.Order))
|
||||
{
|
||||
await connection.QuerySingleAsync<int>(
|
||||
"dbo.Scene_Save",
|
||||
new
|
||||
{
|
||||
SceneID = 0,
|
||||
ChapterID = chapterId,
|
||||
scene.SceneNumber,
|
||||
SceneTitle = Clean(scene.Title),
|
||||
Summary = CombineText(
|
||||
scene.Summary,
|
||||
MetadataLine("POV", scene.PovCharacterName),
|
||||
MetadataLine("Location", scene.LocationName),
|
||||
scene.Notes,
|
||||
ImportMarker),
|
||||
TimeModeID = lookupIds.TimeModeID,
|
||||
StartDateTime = (DateTime?)null,
|
||||
EndDateTime = (DateTime?)null,
|
||||
DurationAmount = (decimal?)null,
|
||||
DurationUnitID = (int?)null,
|
||||
RelativeTimeText = Truncate(scene.DateTimeText, 200),
|
||||
TimeConfidenceID = lookupIds.TimeConfidenceID,
|
||||
ScenePurposeNotes = CombineText(scene.PurposeText),
|
||||
SceneOutcomeNotes = CombineText(scene.OutcomeText),
|
||||
RevisionStatusID = lookupIds.RevisionStatusID
|
||||
},
|
||||
transaction,
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
return new ImportResult
|
||||
{
|
||||
Succeeded = true,
|
||||
ProjectID = projectId,
|
||||
Message = "Import complete."
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ImportLookupIds> GetLookupIdsAsync(IDbConnection connection, IDbTransaction transaction)
|
||||
{
|
||||
var revisionStatusId = await connection.QuerySingleOrDefaultAsync<int?>(
|
||||
"SELECT TOP (1) RevisionStatusID FROM dbo.RevisionStatuses WHERE StatusName = N'Planned' ORDER BY SortOrder;",
|
||||
transaction: transaction);
|
||||
var timeModeId = await connection.QuerySingleOrDefaultAsync<int?>(
|
||||
"SELECT TOP (1) TimeModeID FROM dbo.TimeModes WHERE TimeModeName = N'Relative' ORDER BY SortOrder;",
|
||||
transaction: transaction);
|
||||
var timeConfidenceId = await connection.QuerySingleOrDefaultAsync<int?>(
|
||||
"SELECT TOP (1) TimeConfidenceID FROM dbo.TimeConfidences WHERE TimeConfidenceName = N'Unknown' ORDER BY SortOrder;",
|
||||
transaction: transaction);
|
||||
|
||||
if (!revisionStatusId.HasValue || !timeModeId.HasValue || !timeConfidenceId.HasValue)
|
||||
{
|
||||
throw new InvalidOperationException("Import lookup values are missing. Run the core database script before importing.");
|
||||
}
|
||||
|
||||
return new ImportLookupIds(revisionStatusId.Value, timeModeId.Value, timeConfidenceId.Value);
|
||||
}
|
||||
|
||||
private static string FormatBookTitle(ImportBookDto book)
|
||||
=> string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title : $"{book.Title}: {book.Subtitle}";
|
||||
|
||||
private static string Clean(string? value) => (value ?? string.Empty).Trim();
|
||||
|
||||
private static string? MetadataLine(string label, string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : $"{label}: {value.Trim()}";
|
||||
|
||||
private static string? Truncate(string? value, int maxLength)
|
||||
{
|
||||
var clean = Clean(value);
|
||||
return clean.Length == 0 ? null : clean[..Math.Min(clean.Length, maxLength)];
|
||||
}
|
||||
|
||||
private static string? CombineText(params string?[] values)
|
||||
{
|
||||
var parts = values
|
||||
.Select(Clean)
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (parts.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder();
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (builder.Length > 0)
|
||||
{
|
||||
builder.AppendLine().AppendLine();
|
||||
}
|
||||
|
||||
builder.Append(part);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private sealed record ImportLookupIds(int RevisionStatusID, int TimeModeID, int TimeConfidenceID);
|
||||
}
|
||||
80
PlotLine/Models/ImportModels.cs
Normal file
80
PlotLine/Models/ImportModels.cs
Normal file
@ -0,0 +1,80 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PlotLine.Models;
|
||||
|
||||
public sealed class PlotLineImportPackage
|
||||
{
|
||||
public string? PackageVersion { get; set; }
|
||||
public string? Source { get; set; }
|
||||
public ImportProjectDto Project { get; set; } = new();
|
||||
public List<ImportBookDto> Books { get; set; } = [];
|
||||
|
||||
[JsonExtensionData]
|
||||
public Dictionary<string, object>? ExtensionData { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ImportProjectDto
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ImportBookDto
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Subtitle { get; set; }
|
||||
public int SeriesOrder { get; set; }
|
||||
public bool DependsOnPreviousBook { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<ImportChapterDto> Chapters { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ImportChapterDto
|
||||
{
|
||||
public decimal ChapterNumber { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public int Order { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<ImportSceneDto> Scenes { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ImportSceneDto
|
||||
{
|
||||
public decimal SceneNumber { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public int Order { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public string? PovCharacterName { get; set; }
|
||||
public string? LocationName { get; set; }
|
||||
public string? DateTimeText { get; set; }
|
||||
public string? PurposeText { get; set; }
|
||||
public string? OutcomeText { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ImportValidationResult
|
||||
{
|
||||
public bool IsValid => Errors.Count == 0;
|
||||
public List<string> Errors { get; } = [];
|
||||
public List<string> Warnings { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class ImportPreview
|
||||
{
|
||||
public PlotLineImportPackage? Package { get; init; }
|
||||
public ImportValidationResult Validation { get; init; } = new();
|
||||
public bool HasDuplicateProjectTitle { get; init; }
|
||||
public int BookCount => Package?.Books.Count ?? 0;
|
||||
public int ChapterCount => Package?.Books.Sum(book => book.Chapters.Count) ?? 0;
|
||||
public int SceneCount => Package?.Books.Sum(book => book.Chapters.Sum(chapter => chapter.Scenes.Count)) ?? 0;
|
||||
}
|
||||
|
||||
public sealed class ImportResult
|
||||
{
|
||||
public bool Succeeded { get; init; }
|
||||
public int? ProjectID { get; init; }
|
||||
public string Message { get; init; } = string.Empty;
|
||||
}
|
||||
@ -39,6 +39,7 @@ public class Program
|
||||
builder.Services.AddScoped<IScenarioRepository, ScenarioRepository>();
|
||||
builder.Services.AddScoped<IArchiveRepository, ArchiveRepository>();
|
||||
builder.Services.AddScoped<IAcceptanceRepository, AcceptanceRepository>();
|
||||
builder.Services.AddScoped<IImportRepository, ImportRepository>();
|
||||
builder.Services.AddScoped<IProjectService, ProjectService>();
|
||||
builder.Services.AddScoped<IBookService, BookService>();
|
||||
builder.Services.AddScoped<IChapterService, ChapterService>();
|
||||
@ -57,6 +58,7 @@ public class Program
|
||||
builder.Services.AddScoped<IScenarioService, ScenarioService>();
|
||||
builder.Services.AddScoped<IArchiveService, ArchiveService>();
|
||||
builder.Services.AddScoped<IAcceptanceService, AcceptanceService>();
|
||||
builder.Services.AddScoped<IImportService, ImportService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
201
PlotLine/Services/ImportServices.cs
Normal file
201
PlotLine/Services/ImportServices.cs
Normal file
@ -0,0 +1,201 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
using PlotLine.ViewModels;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface IImportService
|
||||
{
|
||||
Task<ImportIndexViewModel> PreviewAsync(ImportIndexViewModel model);
|
||||
Task<ImportResult> ImportAsync(ImportIndexViewModel model);
|
||||
}
|
||||
|
||||
public sealed class ImportService(IImportRepository imports) : IImportService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
public async Task<ImportIndexViewModel> PreviewAsync(ImportIndexViewModel model)
|
||||
{
|
||||
model.JsonText = await ReadJsonTextAsync(model);
|
||||
model.Preview = await BuildPreviewAsync(model.JsonText);
|
||||
return model;
|
||||
}
|
||||
|
||||
public async Task<ImportResult> ImportAsync(ImportIndexViewModel model)
|
||||
{
|
||||
model.JsonText = await ReadJsonTextAsync(model);
|
||||
var preview = await BuildPreviewAsync(model.JsonText);
|
||||
model.Preview = preview;
|
||||
|
||||
if (!preview.Validation.IsValid || preview.Package is null)
|
||||
{
|
||||
return new ImportResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = "The JSON needs to pass validation before it can be imported."
|
||||
};
|
||||
}
|
||||
|
||||
if (preview.HasDuplicateProjectTitle && !model.AllowDuplicateProjectTitle)
|
||||
{
|
||||
return new ImportResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = "A project with this title already exists. Confirm that you want to import another project with the same title."
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await imports.ImportAsync(preview.Package);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ImportResult
|
||||
{
|
||||
Succeeded = false,
|
||||
Message = $"The import could not be completed, so no data was saved. {ex.Message}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ImportPreview> BuildPreviewAsync(string jsonText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(jsonText))
|
||||
{
|
||||
var emptyValidation = new ImportValidationResult();
|
||||
emptyValidation.Errors.Add("Paste JSON or upload a JSON file before previewing.");
|
||||
return new ImportPreview { Validation = emptyValidation };
|
||||
}
|
||||
|
||||
PlotLineImportPackage? package;
|
||||
try
|
||||
{
|
||||
package = JsonSerializer.Deserialize<PlotLineImportPackage>(jsonText, JsonOptions);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var invalidJson = new ImportValidationResult();
|
||||
invalidJson.Errors.Add($"The JSON could not be read: {ex.Message}");
|
||||
return new ImportPreview { Validation = invalidJson };
|
||||
}
|
||||
|
||||
if (package is null)
|
||||
{
|
||||
var invalidPackage = new ImportValidationResult();
|
||||
invalidPackage.Errors.Add("The JSON import package is empty.");
|
||||
return new ImportPreview { Validation = invalidPackage };
|
||||
}
|
||||
|
||||
var validation = Validate(package);
|
||||
AddMetadataWarnings(package, validation);
|
||||
var duplicate = !string.IsNullOrWhiteSpace(package.Project.Title)
|
||||
&& await imports.ProjectTitleExistsAsync(package.Project.Title.Trim());
|
||||
|
||||
if (duplicate)
|
||||
{
|
||||
validation.Warnings.Add("A project with the same title already exists. Import is blocked until you confirm that duplicate title.");
|
||||
}
|
||||
|
||||
return new ImportPreview
|
||||
{
|
||||
Package = package,
|
||||
Validation = validation,
|
||||
HasDuplicateProjectTitle = duplicate
|
||||
};
|
||||
}
|
||||
|
||||
private static ImportValidationResult Validate(PlotLineImportPackage package)
|
||||
{
|
||||
var result = new ImportValidationResult();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(package.Project.Title))
|
||||
{
|
||||
result.Errors.Add("Project title is required.");
|
||||
}
|
||||
|
||||
if (package.Books.Count == 0)
|
||||
{
|
||||
result.Warnings.Add("No books were supplied. The import will create an empty project.");
|
||||
}
|
||||
|
||||
foreach (var group in package.Books.GroupBy(x => x.SeriesOrder).Where(x => x.Count() > 1))
|
||||
{
|
||||
result.Errors.Add($"Book order {group.Key} is used more than once.");
|
||||
}
|
||||
|
||||
for (var bookIndex = 0; bookIndex < package.Books.Count; bookIndex++)
|
||||
{
|
||||
var book = package.Books[bookIndex];
|
||||
var bookLabel = string.IsNullOrWhiteSpace(book.Title) ? $"Book {bookIndex + 1}" : book.Title;
|
||||
if (string.IsNullOrWhiteSpace(book.Title))
|
||||
{
|
||||
result.Errors.Add($"Book {bookIndex + 1} is missing a title.");
|
||||
}
|
||||
|
||||
foreach (var group in book.Chapters.GroupBy(x => x.Order).Where(x => x.Count() > 1))
|
||||
{
|
||||
result.Errors.Add($"{bookLabel} has duplicate chapter order {group.Key}.");
|
||||
}
|
||||
|
||||
for (var chapterIndex = 0; chapterIndex < book.Chapters.Count; chapterIndex++)
|
||||
{
|
||||
var chapter = book.Chapters[chapterIndex];
|
||||
var chapterLabel = string.IsNullOrWhiteSpace(chapter.Title) ? $"chapter {chapterIndex + 1}" : chapter.Title;
|
||||
if (string.IsNullOrWhiteSpace(chapter.Title))
|
||||
{
|
||||
result.Errors.Add($"{bookLabel} chapter {chapterIndex + 1} is missing a title.");
|
||||
}
|
||||
|
||||
foreach (var group in chapter.Scenes.GroupBy(x => x.Order).Where(x => x.Count() > 1))
|
||||
{
|
||||
result.Errors.Add($"{bookLabel} / {chapterLabel} has duplicate scene order {group.Key}.");
|
||||
}
|
||||
|
||||
for (var sceneIndex = 0; sceneIndex < chapter.Scenes.Count; sceneIndex++)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(chapter.Scenes[sceneIndex].Title))
|
||||
{
|
||||
result.Errors.Add($"{bookLabel} / {chapterLabel} scene {sceneIndex + 1} is missing a title.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void AddMetadataWarnings(PlotLineImportPackage package, ImportValidationResult validation)
|
||||
{
|
||||
var hasPov = package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName))));
|
||||
var hasLocation = package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.LocationName))));
|
||||
|
||||
if (hasPov)
|
||||
{
|
||||
validation.Warnings.Add("POV character names will be kept as imported text notes in scene summaries. Character records are not created in Phase 1A.");
|
||||
}
|
||||
|
||||
if (hasLocation)
|
||||
{
|
||||
validation.Warnings.Add("Location names will be kept as imported text notes in scene summaries. Location records are not created in Phase 1A.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ReadJsonTextAsync(ImportIndexViewModel model)
|
||||
{
|
||||
if (model.JsonFile is { Length: > 0 })
|
||||
{
|
||||
using var reader = new StreamReader(model.JsonFile.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
|
||||
return await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
return model.JsonText ?? string.Empty;
|
||||
}
|
||||
}
|
||||
180
PlotLine/ViewModels/ImportViewModels.cs
Normal file
180
PlotLine/ViewModels/ImportViewModels.cs
Normal file
@ -0,0 +1,180 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.ViewModels;
|
||||
|
||||
public sealed class ImportIndexViewModel
|
||||
{
|
||||
public string JsonText { get; set; } = string.Empty;
|
||||
public IFormFile? JsonFile { get; set; }
|
||||
public bool AllowDuplicateProjectTitle { get; set; }
|
||||
public ImportPreview? Preview { get; set; }
|
||||
public string? StatusMessage { get; set; }
|
||||
public bool CanImport => Preview is { Validation.IsValid: true } && (!Preview.HasDuplicateProjectTitle || AllowDuplicateProjectTitle);
|
||||
|
||||
public static string SampleJson => """
|
||||
{
|
||||
"packageVersion": "1.0",
|
||||
"source": "JSON Import Phase 1A sample",
|
||||
"project": {
|
||||
"title": "The Glass Meridian",
|
||||
"description": "A placeholder two-book fantasy outline.",
|
||||
"notes": "Imported sample data for testing."
|
||||
},
|
||||
"books": [
|
||||
{
|
||||
"title": "Book One",
|
||||
"subtitle": "The Map Beneath the City",
|
||||
"seriesOrder": 1,
|
||||
"dependsOnPreviousBook": false,
|
||||
"description": "The opening movement of the story.",
|
||||
"notes": "Keep this book focused on discovery.",
|
||||
"chapters": [
|
||||
{
|
||||
"chapterNumber": 1,
|
||||
"title": "A Door in the Rain",
|
||||
"order": 1,
|
||||
"summary": "The protagonist finds a hidden route under the old district.",
|
||||
"notes": "Establish the central mystery.",
|
||||
"scenes": [
|
||||
{
|
||||
"sceneNumber": 1,
|
||||
"title": "The Locked Arcade",
|
||||
"order": 1,
|
||||
"summary": "A late delivery leads to an impossible doorway.",
|
||||
"povCharacterName": "Mara",
|
||||
"locationName": "Old Arcade",
|
||||
"dateTimeText": "Rainy evening",
|
||||
"purposeText": "Introduce the mystery",
|
||||
"outcomeText": "Mara keeps the brass key",
|
||||
"notes": "Quiet, tense opening."
|
||||
},
|
||||
{
|
||||
"sceneNumber": 2,
|
||||
"title": "Footsteps Below",
|
||||
"order": 2,
|
||||
"summary": "Mara hears someone moving beneath the street.",
|
||||
"povCharacterName": "Mara",
|
||||
"locationName": "Service stair",
|
||||
"dateTimeText": "Same evening",
|
||||
"purposeText": "Escalate the discovery",
|
||||
"outcomeText": "She chooses to descend",
|
||||
"notes": "End on a decision."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"chapterNumber": 2,
|
||||
"title": "The Lantern Court",
|
||||
"order": 2,
|
||||
"summary": "The hidden route opens into a forgotten civic space.",
|
||||
"notes": "Add wonder, but keep danger near.",
|
||||
"scenes": [
|
||||
{
|
||||
"sceneNumber": 1,
|
||||
"title": "A City Under Glass",
|
||||
"order": 1,
|
||||
"summary": "Mara sees the underground court for the first time.",
|
||||
"povCharacterName": "Mara",
|
||||
"locationName": "Lantern Court",
|
||||
"dateTimeText": "Later that night",
|
||||
"purposeText": "Reveal the hidden setting",
|
||||
"outcomeText": "Mara realizes the map is incomplete",
|
||||
"notes": "Use strong visual contrast."
|
||||
},
|
||||
{
|
||||
"sceneNumber": 2,
|
||||
"title": "The Watchman's Offer",
|
||||
"order": 2,
|
||||
"summary": "A stranger offers help at a cost.",
|
||||
"povCharacterName": "Mara",
|
||||
"locationName": "Lantern Court",
|
||||
"dateTimeText": "Later that night",
|
||||
"purposeText": "Introduce an uneasy ally",
|
||||
"outcomeText": "Mara accepts a temporary guide",
|
||||
"notes": "Trust should feel provisional."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Book Two",
|
||||
"subtitle": "The Clockwork Shore",
|
||||
"seriesOrder": 2,
|
||||
"dependsOnPreviousBook": true,
|
||||
"description": "The story widens beyond the city.",
|
||||
"notes": "Seed consequences from book one.",
|
||||
"chapters": [
|
||||
{
|
||||
"chapterNumber": 1,
|
||||
"title": "Tide Tables",
|
||||
"order": 1,
|
||||
"summary": "The route points toward a mechanical coastline.",
|
||||
"notes": "Reorient the cast after the first book.",
|
||||
"scenes": [
|
||||
{
|
||||
"sceneNumber": 1,
|
||||
"title": "Departure Bell",
|
||||
"order": 1,
|
||||
"summary": "The expedition leaves before dawn.",
|
||||
"povCharacterName": "Ilen",
|
||||
"locationName": "East Station",
|
||||
"dateTimeText": "Early morning",
|
||||
"purposeText": "Start the next journey",
|
||||
"outcomeText": "The group leaves the city",
|
||||
"notes": "Keep momentum high."
|
||||
},
|
||||
{
|
||||
"sceneNumber": 2,
|
||||
"title": "Salt in the Gears",
|
||||
"order": 2,
|
||||
"summary": "The first coastal machine fails in a revealing way.",
|
||||
"povCharacterName": "Ilen",
|
||||
"locationName": "Clockwork Shore",
|
||||
"dateTimeText": "Midday",
|
||||
"purposeText": "Show the new world's rules",
|
||||
"outcomeText": "They find a damaged signal plate",
|
||||
"notes": "Make the mechanism readable."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"chapterNumber": 2,
|
||||
"title": "The Signal Plate",
|
||||
"order": 2,
|
||||
"summary": "The signal points to a rival expedition.",
|
||||
"notes": "Bring in pressure from outside the group.",
|
||||
"scenes": [
|
||||
{
|
||||
"sceneNumber": 1,
|
||||
"title": "A Message in Brass",
|
||||
"order": 1,
|
||||
"summary": "The plate reveals a warning.",
|
||||
"povCharacterName": "Mara",
|
||||
"locationName": "Shore workshop",
|
||||
"dateTimeText": "Afternoon",
|
||||
"purposeText": "Translate the clue",
|
||||
"outcomeText": "The warning names a missing captain",
|
||||
"notes": "Make the clue actionable."
|
||||
},
|
||||
{
|
||||
"sceneNumber": 2,
|
||||
"title": "Smoke on the Causeway",
|
||||
"order": 2,
|
||||
"summary": "A rival camp appears across the water.",
|
||||
"povCharacterName": "Mara",
|
||||
"locationName": "Tidal causeway",
|
||||
"dateTimeText": "Sunset",
|
||||
"purposeText": "Introduce opposition",
|
||||
"outcomeText": "The group prepares to cross",
|
||||
"notes": "End with forward motion."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
}
|
||||
137
PlotLine/Views/Imports/Index.cshtml
Normal file
137
PlotLine/Views/Imports/Index.cshtml
Normal file
@ -0,0 +1,137 @@
|
||||
@model ImportIndexViewModel
|
||||
@{
|
||||
ViewData["Title"] = "JSON Import";
|
||||
var preview = Model.Preview;
|
||||
}
|
||||
|
||||
<div class="page-heading compact">
|
||||
<div>
|
||||
<p class="eyebrow">Tools</p>
|
||||
<h1>JSON Import</h1>
|
||||
<p class="lead-text">Paste or upload a Phase 1A package, preview it, then import a new project when validation passes.</p>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Index">Back to projects</a>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.StatusMessage))
|
||||
{
|
||||
<div class="alert alert-warning">@Model.StatusMessage</div>
|
||||
}
|
||||
|
||||
<section class="edit-panel import-panel">
|
||||
<form asp-action="Preview" method="post" enctype="multipart/form-data">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label asp-for="JsonFile" class="form-label">Upload JSON file</label>
|
||||
<input asp-for="JsonFile" class="form-control" type="file" accept=".json,application/json" />
|
||||
<div class="form-text">Uploading a file replaces the pasted text for this preview.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label asp-for="JsonText" class="form-label">JSON package</label>
|
||||
<textarea asp-for="JsonText" class="form-control import-json-input" rows="22" spellcheck="false"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row mt-3">
|
||||
<button class="btn btn-primary" type="submit">Validate and preview</button>
|
||||
<button class="btn btn-outline-secondary" type="button" data-copy-sample>Use sample JSON</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@if (preview is not null)
|
||||
{
|
||||
<section class="list-section import-preview-section mt-3">
|
||||
<div class="import-preview-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Preview</p>
|
||||
<h2>@(preview.Package?.Project.Title ?? "Import package")</h2>
|
||||
</div>
|
||||
<div class="import-counts">
|
||||
<span><strong>@preview.BookCount</strong> books</span>
|
||||
<span><strong>@preview.ChapterCount</strong> chapters</span>
|
||||
<span><strong>@preview.SceneCount</strong> scenes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (preview.Validation.Errors.Any())
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
<strong>Validation needs attention</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
@foreach (var error in preview.Validation.Errors)
|
||||
{
|
||||
<li>@error</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (preview.Validation.Warnings.Any())
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<strong>Import notes</strong>
|
||||
<ul class="mb-0 mt-2">
|
||||
@foreach (var warning in preview.Validation.Warnings)
|
||||
{
|
||||
<li>@warning</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (preview.Package is not null)
|
||||
{
|
||||
<div class="import-tree">
|
||||
@foreach (var book in preview.Package.Books.OrderBy(x => x.SeriesOrder))
|
||||
{
|
||||
<article class="import-tree-book">
|
||||
<h3>Book @book.SeriesOrder: @(string.IsNullOrWhiteSpace(book.Subtitle) ? book.Title : $"{book.Title}: {book.Subtitle}")</h3>
|
||||
@foreach (var chapter in book.Chapters.OrderBy(x => x.Order))
|
||||
{
|
||||
<div class="import-tree-chapter">
|
||||
<strong>Chapter @chapter.ChapterNumber: @chapter.Title</strong>
|
||||
<ul>
|
||||
@foreach (var scene in chapter.Scenes.OrderBy(x => x.Order))
|
||||
{
|
||||
<li>Scene @scene.SceneNumber: @scene.Title</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
|
||||
<form asp-action="Import" method="post" class="import-confirm-form mt-3">
|
||||
<input asp-for="JsonText" type="hidden" />
|
||||
@if (preview.HasDuplicateProjectTitle)
|
||||
{
|
||||
<div class="form-check import-duplicate-confirm">
|
||||
<input asp-for="AllowDuplicateProjectTitle" class="form-check-input" />
|
||||
<label asp-for="AllowDuplicateProjectTitle" class="form-check-label">
|
||||
Import anyway and create another project with this same title.
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
<button class="btn btn-primary mt-3" type="submit" disabled="@(!Model.CanImport)">Import project</button>
|
||||
</form>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<script type="application/json" id="importSampleJson">@Html.Raw(System.Text.Json.JsonSerializer.Serialize(ImportIndexViewModel.SampleJson))</script>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
(() => {
|
||||
const button = document.querySelector("[data-copy-sample]");
|
||||
const textarea = document.querySelector("#JsonText");
|
||||
const sample = document.querySelector("#importSampleJson");
|
||||
if (!button || !textarea || !sample) return;
|
||||
button.addEventListener("click", () => {
|
||||
textarea.value = JSON.parse(sample.textContent);
|
||||
textarea.focus();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
@ -8,7 +8,10 @@
|
||||
<p class="eyebrow">Series planning</p>
|
||||
<h1>Projects</h1>
|
||||
</div>
|
||||
<a class="btn btn-primary" asp-action="Create">New project</a>
|
||||
<div class="button-row">
|
||||
<a class="btn btn-outline-primary" asp-controller="Imports" asp-action="Index">Import JSON</a>
|
||||
<a class="btn btn-primary" asp-action="Create">New project</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!Model.Any())
|
||||
|
||||
@ -24,6 +24,9 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark" asp-area="" asp-controller="Projects" asp-action="Index">Projects</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark" asp-area="" asp-controller="Imports" asp-action="Index">Import</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link text-dark" asp-area="" asp-controller="Writer" asp-action="Index">Writer Workspace</a>
|
||||
</li>
|
||||
@ -45,6 +48,10 @@
|
||||
{
|
||||
<div class="alert alert-warning">@archiveError</div>
|
||||
}
|
||||
@if (TempData["ImportMessage"] is string importMessage)
|
||||
{
|
||||
<div class="alert alert-info">@importMessage</div>
|
||||
}
|
||||
@RenderBody()
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -105,7 +105,8 @@ a:hover {
|
||||
.warning-card,
|
||||
.warning-mini-card,
|
||||
.scenario-chapter,
|
||||
.drag-drop-panel {
|
||||
.drag-drop-panel,
|
||||
.import-tree-book {
|
||||
background: var(--plotline-surface);
|
||||
border-color: var(--plotline-border-soft);
|
||||
border-radius: var(--plotline-radius);
|
||||
|
||||
@ -169,6 +169,85 @@ td.text-end {
|
||||
max-width: 980px;
|
||||
}
|
||||
|
||||
.import-panel,
|
||||
.import-preview-section {
|
||||
max-width: 1180px;
|
||||
}
|
||||
|
||||
.import-json-input {
|
||||
font-family: Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.45;
|
||||
min-height: 34rem;
|
||||
}
|
||||
|
||||
.import-preview-heading,
|
||||
.import-counts {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.import-preview-heading {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.import-counts {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.import-counts span {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 5px;
|
||||
border: 1px solid var(--plotline-line);
|
||||
border-radius: 999px;
|
||||
padding: 5px 11px;
|
||||
background: var(--plotline-soft);
|
||||
color: var(--plotline-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.import-counts strong {
|
||||
color: var(--plotline-ink);
|
||||
}
|
||||
|
||||
.import-tree {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.import-tree-book {
|
||||
border: 1px solid var(--plotline-line);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: rgba(255, 255, 255, 0.54);
|
||||
}
|
||||
|
||||
.import-tree-book h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.import-tree-chapter {
|
||||
border-top: 1px solid var(--plotline-line);
|
||||
padding-top: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.import-tree-chapter ul {
|
||||
margin: 8px 0 0;
|
||||
color: var(--plotline-muted);
|
||||
}
|
||||
|
||||
.import-duplicate-confirm {
|
||||
border: 1px solid rgba(184, 118, 43, 0.34);
|
||||
border-radius: 8px;
|
||||
padding: 12px 12px 12px 38px;
|
||||
background: rgba(251, 236, 211, 0.72);
|
||||
}
|
||||
|
||||
.scene-editor {
|
||||
max-width: 1180px;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user