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); } public sealed class ImportRepository(ISqlConnectionFactory connectionFactory) : IImportRepository { private const string ImportMarker = "Imported via JSON Import Phase 1A"; 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) { using var connection = connectionFactory.CreateConnection(); connection.Open(); using var transaction = connection.BeginTransaction(); try { var lookupIds = await GetLookupIdsAsync(connection, transaction); var projectId = await connection.QuerySingleAsync( "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( "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( "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( "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 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 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); }