using FlatRender.ContentSvc.Domain.Entities; using FlatRender.ContentSvc.Domain.Enums; using FlatRender.ContentSvc.Infrastructure.Data; using FlatRender.ContentSvc.Models; using Microsoft.EntityFrameworkCore; namespace FlatRender.ContentSvc.Application.Services; /// /// Imports a scanned After Effects project structure (scenes / frl_ frd_ elements / /// frd_ & frshare colours) into a project. Supports a dry-run /// that reports the diff without writing, and an that merges: /// matched items (by key) are refreshed, new items added, manual edits preserved. /// public class AepImportService(ContentDbContext db) { public Task PreviewAsync(Guid projectId, ScanResult scan) => RunAsync(projectId, scan, null); public Task ApplyAsync(Guid projectId, ScanResult scan, ScanApplyOptions options) => RunAsync(projectId, scan, options); private async Task RunAsync(Guid projectId, ScanResult scan, ScanApplyOptions? apply) { // Load ALL scenes incl. soft-deleted: the UNIQUE(project_id,key) constraint // covers deleted rows too, so a re-scan must REVIVE a soft-deleted match // rather than insert a duplicate (which would violate the constraint). // IgnoreQueryFilters() is REQUIRED — Scene has a global DeletedAt==null filter // that would otherwise hide the soft-deleted rows we need to revive. var existing = await db.Scenes .IgnoreQueryFilters() .Where(s => s.ProjectId == projectId) .Include(s => s.ContentElements) .Include(s => s.ColorElements) .ToListAsync(); var existingShared = await db.SharedColors.Where(c => c.ProjectId == projectId).ToListAsync(); var scanScenes = scan.Scenes ?? new List(); var byKey = existing.ToDictionary(s => s.Key, StringComparer.Ordinal); var scanKeys = new HashSet(scanScenes.Select(s => s.Key), StringComparer.Ordinal); int sAdded = 0, sChanged = 0, sUnchanged = 0; var diffs = new List(); int sortBase = existing.Count; foreach (var ss in scanScenes) { byKey.TryGetValue(ss.Key, out var scene); bool isNew = scene is null; var exElems = scene?.ContentElements.ToList() ?? new List(); var exColors = scene?.ColorElements.ToList() ?? new List(); var scElems = ss.Elements ?? new List(); var scColors = ss.Colors ?? new List(); var exElemByKey = exElems.ToDictionary(e => e.Key, StringComparer.Ordinal); var exColorByKey = exColors.ToDictionary(c => c.ElementKey, StringComparer.Ordinal); var scElemKeys = new HashSet(scElems.Select(e => e.Key), StringComparer.Ordinal); var scColorKeys = new HashSet(scColors.Select(c => c.ElementKey), StringComparer.Ordinal); int eAdded = scElems.Count(e => !exElemByKey.ContainsKey(e.Key)); int eChanged = scElems.Count(e => exElemByKey.TryGetValue(e.Key, out var ex) && ElementDiffers(e, ex)); int eRemoved = exElems.Count(e => !scElemKeys.Contains(e.Key)); int cAdded = scColors.Count(c => !exColorByKey.ContainsKey(c.ElementKey)); int cChanged = scColors.Count(c => exColorByKey.TryGetValue(c.ElementKey, out var ex) && ColorDiffers(c, ex)); int cRemoved = exColors.Count(c => !scColorKeys.Contains(c.ElementKey)); string status; if (isNew) { status = "added"; sAdded++; } else if (eAdded + eChanged + eRemoved + cAdded + cChanged + cRemoved > 0 || SceneHeaderDiffers(ss, scene!)) { status = "changed"; sChanged++; } else { status = "unchanged"; sUnchanged++; } diffs.Add(new SceneDiff(ss.Key, ss.Title ?? ss.Key, status, eAdded, eChanged, eRemoved, cAdded, cChanged, cRemoved)); if (apply is not null) { if (isNew) { scene = new Scene { ProjectId = projectId, Key = ss.Key, Sort = ss.Sort ?? sortBase++ }; db.Scenes.Add(scene); } else if (scene!.DeletedAt is not null) { scene.DeletedAt = null; // revive a soft-deleted scene the scan re-discovered } ApplySceneHeader(scene!, ss, isNew, apply); ApplyElements(scene!, scElems, exElemByKey, scElemKeys, apply); ApplyColors(scene!, scColors, exColorByKey, scColorKeys, apply); } } // Orphans = currently-active scenes the scan no longer contains (ignore // already soft-deleted rows so they don't inflate the diff). var orphans = existing.Where(s => s.DeletedAt == null && !scanKeys.Contains(s.Key)).ToList(); if (apply is not null && apply.RemoveOrphanScenes) foreach (var s in orphans) s.DeletedAt = DateTime.UtcNow; var (shAdded, shChanged, shRemoved) = MergeShared(projectId, scan.SharedColors ?? new List(), existingShared, apply); if (apply is not null) await db.SaveChangesAsync(); return new ImportDiff( apply is not null, sAdded, sChanged, sUnchanged, orphans.Count, shAdded, shChanged, shRemoved, diffs, orphans.Select(o => o.Key).ToList()); } // ── scene header ───────────────────────────────────────────────────────── private static bool SceneHeaderDiffers(ScanScene ss, Scene e) => (ss.SceneType is not null && ParseScene(ss.SceneType) != e.SceneType) || (ss.DefaultDurationSec is not null && ss.DefaultDurationSec != e.DefaultDurationSec) || (ss.Title is not null && ss.Title != e.Title); private static void ApplySceneHeader(Scene scene, ScanScene ss, bool isNew, ScanApplyOptions o) { if (isNew || o.OverwriteExisting) { scene.Title = ss.Title ?? scene.Title ?? ss.Key; if (ss.SceneType is not null) scene.SceneType = ParseScene(ss.SceneType); if (ss.DefaultDurationSec is not null) scene.DefaultDurationSec = ss.DefaultDurationSec; if (ss.MinDurationSec is not null) scene.MinDurationSec = ss.MinDurationSec; if (ss.MaxDurationSec is not null) scene.MaxDurationSec = ss.MaxDurationSec; if (ss.Sort is not null) scene.Sort = ss.Sort.Value; scene.UpdatedAt = DateTime.UtcNow; } if (isNew && string.IsNullOrEmpty(scene.Title)) scene.Title = ss.Key; } // ── content elements ────────────────────────────────────────────────────── private static bool ElementDiffers(ScanElement s, SceneContentElement e) => (s.Type is not null && ParseElemType(s.Type) != e.Type) || (s.DefaultValue is not null && s.DefaultValue != e.DefaultValue) || (s.FontFace is not null && s.FontFace != e.FontFace) || (s.FontSize is not null && s.FontSize != e.FontSize) || (s.Justify is not null && ParseJustify(s.Justify) != e.Justify) || (s.IsHidden is not null && s.IsHidden != e.IsHidden); private void ApplyElements(Scene scene, List scElems, Dictionary exByKey, HashSet scKeys, ScanApplyOptions o) { int sort = 0; foreach (var se in scElems) { if (exByKey.TryGetValue(se.Key, out var e)) { if (o.OverwriteExisting) WriteElement(e, se, scene.Id, sort); } else { var ne = new SceneContentElement { SceneId = scene.Id, Key = se.Key }; WriteElement(ne, se, scene.Id, sort); db.SceneContentElements.Add(ne); } sort++; } if (o.RemoveOrphanElements) foreach (var e in exByKey.Values.Where(e => !scKeys.Contains(e.Key))) db.SceneContentElements.Remove(e); } private static void WriteElement(SceneContentElement e, ScanElement s, Guid sceneId, int sort) { e.SceneId = sceneId; e.Title = s.Title ?? (string.IsNullOrEmpty(e.Title) ? s.Key : e.Title); if (s.Type is not null) e.Type = ParseElemType(s.Type); if (s.DefaultValue is not null) e.DefaultValue = s.DefaultValue; if (s.FontFace is not null) { e.FontFace = s.FontFace; e.DefaultFontFace = s.FontFace; } if (s.FontFaceName is not null) e.FontFaceName = s.FontFaceName; if (s.FontSize is not null) { e.FontSize = s.FontSize; e.DefaultFontSize = s.FontSize; } if (s.Justify is not null) e.Justify = ParseJustify(s.Justify); if (s.PositionInContainer is not null) e.PositionInContainer = s.PositionInContainer.Value; if (s.IsTextBox is not null) e.IsTextBox = s.IsTextBox.Value; if (s.MaxSize is not null) e.MaxSize = s.MaxSize; if (s.VideoSupport is not null) e.VideoSupport = s.VideoSupport.Value; if (s.Width is not null) e.Width = s.Width; if (s.Height is not null) e.Height = s.Height; if (s.IsHidden is not null) e.IsHidden = s.IsHidden.Value; if (s.DirectionLayerKey is not null) e.DirectionLayerKey = s.DirectionLayerKey; e.Sort = s.Sort ?? sort; e.UpdatedAt = DateTime.UtcNow; } // ── colour elements (per scene) ─────────────────────────────────────────── private static bool ColorDiffers(ScanColor s, SceneColorElement e) => (s.DefaultColor is not null && !ColorEq(s.DefaultColor, e.DefaultColor)) || (s.AttrValue is not null && ParseAttr(s.AttrValue) != e.AttrValue) || (s.Title is not null && s.Title != e.Title); private void ApplyColors(Scene scene, List scColors, Dictionary exByKey, HashSet scKeys, ScanApplyOptions o) { int sort = 0; foreach (var sc in scColors) { if (exByKey.TryGetValue(sc.ElementKey, out var e)) { if (o.OverwriteExisting) WriteColor(e, sc, scene.Id, sort); } else { var ne = new SceneColorElement { SceneId = scene.Id, ElementKey = sc.ElementKey, DefaultColor = sc.DefaultColor ?? "#000000" }; WriteColor(ne, sc, scene.Id, sort); db.SceneColorElements.Add(ne); } sort++; } if (o.RemoveOrphanElements) foreach (var e in exByKey.Values.Where(e => !scKeys.Contains(e.ElementKey))) db.SceneColorElements.Remove(e); } private static void WriteColor(SceneColorElement e, ScanColor s, Guid sceneId, int sort) { e.SceneId = sceneId; e.Title = s.Title ?? (string.IsNullOrEmpty(e.Title) ? s.ElementKey : e.Title); if (s.AttrValue is not null) e.AttrValue = ParseAttr(s.AttrValue); if (s.DefaultColor is not null) e.DefaultColor = s.DefaultColor; e.Sort = s.Sort ?? sort; e.UpdatedAt = DateTime.UtcNow; } // ── shared colours (project-level) ──────────────────────────────────────── private (int added, int changed, int removed) MergeShared( Guid projectId, List scan, List existing, ScanApplyOptions? apply) { var byKey = existing.ToDictionary(c => c.ElementKey, StringComparer.Ordinal); var scanKeys = new HashSet(scan.Select(c => c.ElementKey), StringComparer.Ordinal); int added = 0, changed = 0, removed = 0, sort = 0; foreach (var sc in scan) { if (byKey.TryGetValue(sc.ElementKey, out var e)) { if (ColorDiffersShared(sc, e)) changed++; if (apply is not null && apply.OverwriteExisting) { e.Title = sc.Title ?? e.Title; if (sc.AttrValue is not null) e.AttrValue = ParseAttr(sc.AttrValue); if (sc.DefaultColor is not null) e.DefaultColor = sc.DefaultColor; e.Sort = sc.Sort ?? e.Sort; e.UpdatedAt = DateTime.UtcNow; } } else { added++; if (apply is not null) db.SharedColors.Add(new SharedColor { ProjectId = projectId, ElementKey = sc.ElementKey, Title = sc.Title ?? sc.ElementKey, AttrValue = ParseAttr(sc.AttrValue), DefaultColor = sc.DefaultColor ?? "#000000", Sort = sc.Sort ?? sort, }); } sort++; } var orphans = existing.Where(c => !scanKeys.Contains(c.ElementKey)).ToList(); removed = orphans.Count; if (apply is not null && apply.RemoveOrphanElements) foreach (var c in orphans) db.SharedColors.Remove(c); return (added, changed, removed); } private static bool ColorDiffersShared(ScanColor s, SharedColor e) => (s.DefaultColor is not null && !ColorEq(s.DefaultColor, e.DefaultColor)) || (s.AttrValue is not null && ParseAttr(s.AttrValue) != e.AttrValue) || (s.Title is not null && s.Title != e.Title); // ── parsing helpers ─────────────────────────────────────────────────────── private static bool ColorEq(string? a, string? b) => string.Equals((a ?? "").Trim().TrimStart('#'), (b ?? "").Trim().TrimStart('#'), StringComparison.OrdinalIgnoreCase); private static SceneKind ParseScene(string? v) => Enum.TryParse(v, true, out var k) ? k : SceneKind.Normal; private static ContentElementType ParseElemType(string? v) => Enum.TryParse(v, true, out var t) ? t : ContentElementType.Text; private static JustifyKind ParseJustify(string? v) => Enum.TryParse(v, true, out var j) ? j : JustifyKind.CENTER_JUSTIFY; private static AttrValueKind ParseAttr(string? v) => Enum.TryParse(v, true, out var a) ? a : AttrValueKind.fill; }