using System.Data; using System.Text; using Dapper; using PlotLine.Models; namespace PlotLine.Data; public interface IImportRepository { Task ProjectTitleExistsAsync(string projectTitle); Task ImportAsync(PlotLineImportPackage package, string? importedProjectTitle); } public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : IImportRepository { private const string ImportMarker = "Imported via JSON Import Phase 1B"; public async Task ProjectTitleExistsAsync(string projectTitle) { using var connection = connectionFactory.CreateConnection(); var count = await connection.ExecuteScalarAsync( """ SELECT COUNT(1) FROM dbo.Projects WHERE IsArchived = 0 AND ProjectName = @ProjectName; """, new { ProjectName = projectTitle }); return count > 0; } public async Task ImportAsync(PlotLineImportPackage package, string? importedProjectTitle) { using var connection = connectionFactory.CreateConnection(); connection.Open(); using var transaction = connection.BeginTransaction(); try { var booksCreated = 0; var chaptersCreated = 0; var scenesCreated = 0; var skippedFields = GetSkippedOptionalFields(package); var projectName = string.IsNullOrWhiteSpace(importedProjectTitle) ? Clean(package.Project.Title) : Clean(importedProjectTitle); var lookupIds = await GetLookupIdsAsync(connection, transaction); var projectId = await connection.QuerySingleAsync( "dbo.Project_Save", new { ProjectID = 0, ProjectName = projectName, Description = CombineText( package.Project.Description, package.Project.Notes, ImportMetadata(package)) }, transaction, commandType: CommandType.StoredProcedure); foreach (var book in package.Books.OrderBy(x => x.SeriesOrder)) { var bookId = await connection.QuerySingleAsync( "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); booksCreated++; foreach (var chapter in book.Chapters.OrderBy(x => x.Order)) { var chapterId = await connection.QuerySingleAsync( "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); chaptersCreated++; foreach (var scene in chapter.Scenes.OrderBy(x => x.Order)) { await connection.QuerySingleAsync( "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); scenesCreated++; } } } transaction.Commit(); return new ImportResult { Succeeded = true, ProjectID = projectId, ProjectName = projectName, BooksCreated = booksCreated, ChaptersCreated = chaptersCreated, ScenesCreated = scenesCreated, SkippedOptionalFields = skippedFields, Message = "Import complete." }; } catch { transaction.Rollback(); throw; } } private static async Task GetLookupIdsAsync(IDbConnection connection, IDbTransaction transaction) { var revisionStatusId = await connection.QuerySingleOrDefaultAsync( "SELECT TOP (1) RevisionStatusID FROM dbo.RevisionStatuses WHERE StatusName = N'Planned' ORDER BY SortOrder;", transaction: transaction); var timeModeId = await connection.QuerySingleOrDefaultAsync( "SELECT TOP (1) TimeModeID FROM dbo.TimeModes WHERE TimeModeName = N'Relative' ORDER BY SortOrder;", transaction: transaction); var timeConfidenceId = await connection.QuerySingleOrDefaultAsync( "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 ImportMetadata(PlotLineImportPackage package) { var source = string.IsNullOrWhiteSpace(package.Source) ? null : $"Source: {package.Source.Trim()}"; return CombineText(ImportMarker, $"Imported at {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC", source) ?? ImportMarker; } private static IReadOnlyList GetSkippedOptionalFields(PlotLineImportPackage package) { var skipped = new List(); if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.PovCharacterName))))) { skipped.Add("POV character names were stored as plain text in scene summaries; Character records were not created."); } if (package.Books.Any(book => book.Chapters.Any(chapter => chapter.Scenes.Any(scene => !string.IsNullOrWhiteSpace(scene.LocationName))))) { skipped.Add("Location names were stored as plain text in scene summaries; Location records were not created."); } return skipped; } 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); }