using System.Text.Json; namespace AIStudio.Assistants.VisualBriefing; public sealed partial class VisualBriefingStore { /// /// Defines LastSelectedBriefingId for the visual briefing feature. /// public Guid? LastSelectedBriefingId { get; private set; } /// /// Defines RememberSelectionAsync for the visual briefing feature. /// public async Task RememberSelectionAsync(Guid briefingId, CancellationToken token = default) { await this.InitializeAsync(token); if (this.LastSelectedBriefingId == briefingId) return; await this.selectionLock.WaitAsync(token); try { this.LastSelectedBriefingId = briefingId; await WriteTextAtomicAsync(this.SelectionPath(), JsonSerializer.Serialize(briefingId), token); } finally { this.selectionLock.Release(); } } /// /// Defines ForgetSelectionAsync for the visual briefing feature. /// public async Task ForgetSelectionAsync(Guid briefingId, CancellationToken token = default) { if (this.LastSelectedBriefingId != briefingId) return; await this.selectionLock.WaitAsync(token); try { if (this.LastSelectedBriefingId != briefingId) return; this.LastSelectedBriefingId = null; await WriteTextAtomicAsync(this.SelectionPath(), JsonSerializer.Serialize(null), token); } finally { this.selectionLock.Release(); } } /// /// Defines LoadSelectionAsync for the visual briefing feature. /// private async Task LoadSelectionAsync(CancellationToken token) { var path = this.SelectionPath(); if (!File.Exists(path)) return; try { var serialized = await File.ReadAllTextAsync(path, token); var selected = JsonSerializer.Deserialize(serialized); this.LastSelectedBriefingId = selected is not null && Directory.Exists(this.BriefingDirectory(selected.Value)) ? selected : null; } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) { logger.LogWarning( new EventId((int)VisualBriefingLogEventId.STORE_REJECTED, VisualBriefingLogEventId.STORE_REJECTED.ToString()), "Could not restore the last selected visual briefing. ExceptionType={ExceptionType}", exception.GetType().Name); this.LastSelectedBriefingId = null; } } /// /// Defines InitializeAsync for the visual briefing feature. /// private async Task InitializeAsync(CancellationToken token = default) { if (this.initialized) return; await this.initializationLock.WaitAsync(token); try { if (this.initialized) return; Directory.CreateDirectory(this.RootDirectory); foreach (var temporaryPath in Directory.EnumerateFiles(this.RootDirectory, "*.tmp-*", SearchOption.AllDirectories)) TryDeleteFile(temporaryPath); await this.LoadSelectionAsync(token); foreach (var directory in Directory.EnumerateDirectories(this.RootDirectory)) { token.ThrowIfCancellationRequested(); if (!Guid.TryParse(Path.GetFileName(directory), out var briefingId)) continue; await this.ReconcileAsync(briefingId, token); } this.initialized = true; } finally { this.initializationLock.Release(); } } /// /// Defines ListAsync for the visual briefing feature. /// public async Task> ListAsync(CancellationToken token = default) { var projects = await this.ListProjectsAsync(token); return [.. projects.Where(project => project.IsAvailable).Select(project => project.Manifest!)]; } /// /// Lists every project directory, including projects whose manifests cannot be opened. /// internal async Task> ListProjectsAsync(CancellationToken token = default) { await this.InitializeAsync(token); List projects = []; foreach (var directory in Directory.EnumerateDirectories(this.RootDirectory)) { token.ThrowIfCancellationRequested(); if (!Guid.TryParse(Path.GetFileName(directory), out var briefingId)) continue; projects.Add(await this.LoadProjectEntryAsync(briefingId, directory, token)); } return projects.OrderByDescending(project => project.ModifiedAtUtc).ToArray(); } /// /// Gets the exact project directory without interpreting or modifying its contents. /// internal async Task GetProjectDirectoryPathAsync(Guid briefingId, CancellationToken token = default) { await this.InitializeAsync(token); var path = this.BriefingDirectory(briefingId); return Directory.Exists(path) ? path : null; } /// /// Loads a normal manifest or returns a recovery entry with best-effort display metadata. /// private async Task LoadProjectEntryAsync(Guid briefingId, string directory, CancellationToken token) { var path = this.ManifestPath(briefingId); var modifiedAtUtc = ProjectModifiedAtUtc(path, directory); if (!File.Exists(path)) return new(briefingId, string.Empty, modifiedAtUtc, VisualBriefingProjectLoadStatus.UNAVAILABLE, null); string json; try { json = await File.ReadAllTextAsync(path, token); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { this.LogUnavailableManifest(briefingId, exception); return new(briefingId, string.Empty, modifiedAtUtc, VisualBriefingProjectLoadStatus.UNAVAILABLE, null); } try { var manifest = JsonSerializer.Deserialize(json, JSON_OPTIONS); if (manifest is not null && IsValidManifest(manifest, briefingId)) { RefreshSourceStatuses(manifest); return VisualBriefingProjectEntry.FromManifest(manifest); } } catch (JsonException exception) { this.LogUnavailableManifest(briefingId, exception); } var (name, persistedModifiedAtUtc, manifestVersion) = ReadProjectMetadata(json); var status = manifestVersion is > VisualBriefingVersions.MANIFEST ? VisualBriefingProjectLoadStatus.NEWER_VERSION : VisualBriefingProjectLoadStatus.UNAVAILABLE; return new(briefingId, name, persistedModifiedAtUtc ?? modifiedAtUtc, status, null); } /// /// Reads only non-authoritative display metadata from an otherwise unusable manifest. /// private static (string Name, DateTimeOffset? ModifiedAtUtc, int? ManifestVersion) ReadProjectMetadata(string json) { try { using var document = JsonDocument.Parse(json); if (document.RootElement.ValueKind is not JsonValueKind.Object) return (string.Empty, null, null); var root = document.RootElement; var name = root.TryGetProperty("name", out var nameElement) && nameElement.ValueKind is JsonValueKind.String ? SanitizeProjectName(nameElement.GetString()) : string.Empty; DateTimeOffset? modifiedAtUtc = root.TryGetProperty("modifiedAtUtc", out var modifiedElement) && modifiedElement.ValueKind is JsonValueKind.String && modifiedElement.TryGetDateTimeOffset(out var parsedModifiedAtUtc) ? parsedModifiedAtUtc : null; int? manifestVersion = root.TryGetProperty("manifestVersion", out var versionElement) && versionElement.ValueKind is JsonValueKind.Number && versionElement.TryGetInt32(out var parsedManifestVersion) ? parsedManifestVersion : null; return (name, modifiedAtUtc, manifestVersion); } catch (JsonException) { return (string.Empty, null, null); } } /// /// Removes control characters and bounds untrusted recovery-list text. /// private static string SanitizeProjectName(string? name) { if (string.IsNullOrWhiteSpace(name)) return string.Empty; var sanitized = new string(name.Where(character => !char.IsControl(character)).ToArray()).Trim(); return sanitized.Length <= 200 ? sanitized : sanitized[..200]; } /// /// Gets a stable fallback timestamp from the manifest or project directory. /// private static DateTimeOffset ProjectModifiedAtUtc(string manifestPath, string directory) { try { var timestamp = File.Exists(manifestPath) ? File.GetLastWriteTimeUtc(manifestPath) : Directory.GetLastWriteTimeUtc(directory); return new DateTimeOffset(timestamp); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { return DateTimeOffset.UnixEpoch; } } /// /// Records why a manifest was exposed through the recovery lane. /// private void LogUnavailableManifest(Guid briefingId, Exception exception) { logger.LogWarning(new EventId((int)VisualBriefingLogEventId.STORE_REJECTED, nameof(VisualBriefingLogEventId.STORE_REJECTED)), exception, "Could not load visual briefing manifest. BriefingId={BriefingId} ExceptionType={ExceptionType}", briefingId, exception.GetType().Name); } /// /// Defines LoadAsync for the visual briefing feature. /// public async Task LoadAsync(Guid briefingId, CancellationToken token = default) { await this.InitializeAsync(token); var path = this.ManifestPath(briefingId); if (!File.Exists(path)) return null; try { await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, true); var manifest = await JsonSerializer.DeserializeAsync(stream, JSON_OPTIONS, token); if (manifest is null || !IsValidManifest(manifest, briefingId)) return null; RefreshSourceStatuses(manifest); return manifest; } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) { logger.LogWarning( new EventId((int)VisualBriefingLogEventId.STORE_REJECTED, nameof(VisualBriefingLogEventId.STORE_REJECTED)), exception, "Could not load visual briefing manifest. BriefingId={BriefingId} ExceptionType={ExceptionType}", briefingId, exception.GetType().Name); return null; } } /// /// Defines CreateAsync for the visual briefing feature. /// public async Task CreateAsync( string name, string author, VisualBriefingLocalSettings settings, Guid? briefingId = null, CancellationToken token = default) { await this.InitializeAsync(token); if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("A briefing name is required.", nameof(name)); var id = briefingId ?? Guid.NewGuid(); var gate = this.GetLock(id); await gate.WaitAsync(token); try { var directory = this.BriefingDirectory(id); if (Directory.Exists(directory)) throw new IOException($"A visual briefing with ID '{id}' already exists."); Directory.CreateDirectory(this.VersionsDirectory(id)); Directory.CreateDirectory(this.TranscriptsDirectory(id)); Directory.CreateDirectory(this.EvidenceArtifactsDirectory(id)); Directory.CreateDirectory(this.PlanArtifactsDirectory(id)); Directory.CreateDirectory(this.ContentArtifactsDirectory(id)); Directory.CreateDirectory(this.PresentationArtifactsDirectory(id)); Directory.CreateDirectory(this.BuildsDirectory(id)); var now = DateTimeOffset.UtcNow; var manifest = new VisualBriefingManifest { BriefingId = id, Name = name.Trim(), Author = author.Trim(), CreatedAtUtc = now, ModifiedAtUtc = now, Settings = settings, }; await this.StoreManifestAtomicAsync(manifest, token); return manifest; } finally { gate.Release(); } } /// /// Defines RenameAsync for the visual briefing feature. /// public async Task RenameAsync(Guid briefingId, string name, CancellationToken token = default) { if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("A briefing name is required.", nameof(name)); await this.MutateManifestAsync(briefingId, manifest => { manifest.Name = name.Trim(); manifest.ModifiedAtUtc = DateTimeOffset.UtcNow; }, token); } /// /// Defines SaveProjectAsync for the visual briefing feature. /// public async Task SaveProjectAsync(Guid briefingId, string name, string author, VisualBriefingLocalSettings settings, IEnumerable<(string Path, VisualBriefingSourceKind Kind)> sources, CancellationToken token = default) { await this.MutateManifestAsync(briefingId, manifest => { if (string.IsNullOrWhiteSpace(name)) throw new InvalidOperationException("A briefing name is required."); manifest.Name = name.Trim(); manifest.Author = author.Trim(); manifest.Settings = settings; var mergedSources = MergeSources(manifest.Sources, sources); var retainedSourceIds = mergedSources.Select(source => source.SourceId).ToHashSet(); foreach (var removedSource in manifest.Sources.Where(source => !retainedSourceIds.Contains(source.SourceId))) TryDeleteFile(this.TranscriptPath(briefingId, removedSource.SourceId)); manifest.Sources = mergedSources; manifest.ModifiedAtUtc = DateTimeOffset.UtcNow; RefreshSourceStatuses(manifest); }, token); } /// /// Defines DeleteAsync for the visual briefing feature. /// public async Task DeleteAsync(Guid briefingId, CancellationToken token = default) { await this.InitializeAsync(token); var gate = this.GetLock(briefingId); await gate.WaitAsync(token); try { var directory = this.BriefingDirectory(briefingId); if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); } finally { gate.Release(); } } /// /// Defines MutateManifestAsync for the visual briefing feature. /// private async Task MutateManifestAsync(Guid briefingId, Action mutation, CancellationToken token) { await this.InitializeAsync(token); var gate = this.GetLock(briefingId); await gate.WaitAsync(token); try { var manifest = await this.LoadRequiredWithoutInitializeAsync(briefingId, token); mutation(manifest); await this.StoreManifestAtomicAsync(manifest, token); } finally { gate.Release(); } } /// /// Defines LoadRequiredWithoutInitializeAsync for the visual briefing feature. /// private async Task LoadRequiredWithoutInitializeAsync(Guid briefingId, CancellationToken token) { var path = this.ManifestPath(briefingId); if (!File.Exists(path)) throw new FileNotFoundException("The visual briefing does not exist.", path); return await this.LoadWithoutInitializeAsync(briefingId, token) ?? throw new InvalidDataException("The visual briefing manifest is invalid."); } /// /// Defines LoadWithoutInitializeAsync for the visual briefing feature. /// private async Task LoadWithoutInitializeAsync(Guid briefingId, CancellationToken token) { var path = this.ManifestPath(briefingId); if (!File.Exists(path)) return null; await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, true); var manifest = await JsonSerializer.DeserializeAsync(stream, JSON_OPTIONS, token); return manifest is not null && IsValidManifest(manifest, briefingId) ? manifest : null; } /// /// Defines StoreManifestAtomicAsync for the visual briefing feature. /// private async Task StoreManifestAtomicAsync(VisualBriefingManifest manifest, CancellationToken token) { var json = JsonSerializer.Serialize(manifest, JSON_OPTIONS); await WriteTextAtomicAsync(this.ManifestPath(manifest.BriefingId), json, token); } /// /// Defines IsValidManifest for the visual briefing feature. /// private static bool IsValidManifest(VisualBriefingManifest manifest, Guid expectedBriefingId) { if (manifest.ManifestVersion is < 1 or > VisualBriefingVersions.MANIFEST || manifest.BriefingId != expectedBriefingId || manifest.BriefingId == Guid.Empty || string.IsNullOrWhiteSpace(manifest.Name) || IsNull(manifest.Settings) || IsNull(manifest.Sources) || IsNull(manifest.Versions) || manifest.Sources.Any(source => source.SourceId == Guid.Empty || string.IsNullOrWhiteSpace(source.Path) || !Path.IsPathFullyQualified(source.Path) || source.Kind is VisualBriefingSourceKind.VISUAL_ASSET && (string.IsNullOrWhiteSpace(source.AssetId) || !IsValidAssetId(source.AssetId))) || manifest.Sources.Select(source => source.SourceId).Distinct().Count() != manifest.Sources.Count || manifest.Sources.Where(source => source.Kind is VisualBriefingSourceKind.VISUAL_ASSET) .Select(source => source.AssetId).Distinct(StringComparer.Ordinal).Count() != manifest.Sources.Count(source => source.Kind is VisualBriefingSourceKind.VISUAL_ASSET)) return false; foreach (var version in manifest.Versions) { if (version.VersionNumber <= 0 || version.RevisionId == Guid.Empty || version.SchemaVersion <= 0 || version.IntermediateArtifactVersion < 0 || version.EvidenceContractVersion < 0 || version.PlanContractVersion < 0 || version.ContentContractVersion < 0 || version.DesignContractVersion < 0 || string.IsNullOrWhiteSpace(version.DocumentHash) || version.DocumentHash.Length != 64 || !version.DocumentHash.All(Uri.IsHexDigit) || !string.Equals( version.FileName, $"{version.VersionNumber:000000}-{version.RevisionId:D}.html", StringComparison.Ordinal)) return false; } return manifest.Versions.Select(version => version.VersionNumber).Distinct().Count() == manifest.Versions.Count && manifest.Versions.Select(version => version.RevisionId).Distinct().Count() == manifest.Versions.Count; } /// /// Defines NamesEqual for the visual briefing feature. /// private static bool NamesEqual(string first, string second) => string.Equals(NormalizeName(first), NormalizeName(second), StringComparison.OrdinalIgnoreCase); /// /// Defines NormalizeName for the visual briefing feature. /// private static string NormalizeName(string value) => string.Join(' ', value.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); }