mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-18 02:57:59 +00:00
Added the visual briefing assistant (#893)
Build and Release / Determine run mode (push) Has been cancelled
Build and Release / Read metadata (push) Has been cancelled
Build and Release / Sync Flatpak repo (push) Has been cancelled
Build and Release / Collect Flatpak artifacts (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Prepare & create release (push) Has been cancelled
Build and Release / Publish release (push) Has been cancelled
Build and Release / Determine run mode (push) Has been cancelled
Build and Release / Read metadata (push) Has been cancelled
Build and Release / Sync Flatpak repo (push) Has been cancelled
Build and Release / Collect Flatpak artifacts (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Prepare & create release (push) Has been cancelled
Build and Release / Publish release (push) Has been cancelled
This commit is contained in:
1 parent
df4663fff4
commit
58cc811a58
214 files changed
+18943
-353
No files matched your search
@@ -0,0 +1,519 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AIStudio.Assistants.VisualBriefing;
|
||||
|
||||
public sealed partial class VisualBriefingStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines <c>LastSelectedBriefingId</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
public Guid? LastSelectedBriefingId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>RememberSelectionAsync</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
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<Guid?>(briefingId), token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.selectionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>ForgetSelectionAsync</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
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<Guid?>(null), token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.selectionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>LoadSelectionAsync</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
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<Guid?>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>InitializeAsync</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>ListAsync</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<VisualBriefingManifest>> ListAsync(CancellationToken token = default)
|
||||
{
|
||||
var projects = await this.ListProjectsAsync(token);
|
||||
return [.. projects.Where(project => project.IsAvailable).Select(project => project.Manifest!)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists every project directory, including projects whose manifests cannot be opened.
|
||||
/// </summary>
|
||||
internal async Task<IReadOnlyList<VisualBriefingProjectEntry>> ListProjectsAsync(CancellationToken token = default)
|
||||
{
|
||||
await this.InitializeAsync(token);
|
||||
List<VisualBriefingProjectEntry> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the exact project directory without interpreting or modifying its contents.
|
||||
/// </summary>
|
||||
internal async Task<string?> GetProjectDirectoryPathAsync(Guid briefingId, CancellationToken token = default)
|
||||
{
|
||||
await this.InitializeAsync(token);
|
||||
var path = this.BriefingDirectory(briefingId);
|
||||
return Directory.Exists(path) ? path : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a normal manifest or returns a recovery entry with best-effort display metadata.
|
||||
/// </summary>
|
||||
private async Task<VisualBriefingProjectEntry> 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<VisualBriefingManifest>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads only non-authoritative display metadata from an otherwise unusable manifest.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes control characters and bounds untrusted recovery-list text.
|
||||
/// </summary>
|
||||
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];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a stable fallback timestamp from the manifest or project directory.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records why a manifest was exposed through the recovery lane.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>LoadAsync</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
public async Task<VisualBriefingManifest?> 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<VisualBriefingManifest>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>CreateAsync</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
public async Task<VisualBriefingManifest> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>RenameAsync</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>SaveProjectAsync</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>DeleteAsync</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>MutateManifestAsync</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
private async Task MutateManifestAsync(Guid briefingId, Action<VisualBriefingManifest> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>LoadRequiredWithoutInitializeAsync</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
private async Task<VisualBriefingManifest> 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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>LoadWithoutInitializeAsync</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
private async Task<VisualBriefingManifest?> 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<VisualBriefingManifest>(stream, JSON_OPTIONS, token);
|
||||
|
||||
return manifest is not null && IsValidManifest(manifest, briefingId) ? manifest : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>StoreManifestAtomicAsync</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
private async Task StoreManifestAtomicAsync(VisualBriefingManifest manifest, CancellationToken token)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(manifest, JSON_OPTIONS);
|
||||
await WriteTextAtomicAsync(this.ManifestPath(manifest.BriefingId), json, token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>IsValidManifest</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>NamesEqual</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
private static bool NamesEqual(string first, string second) => string.Equals(NormalizeName(first), NormalizeName(second), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>NormalizeName</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
private static string NormalizeName(string value) => string.Join(' ', value.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
Reference in new issue
Block a user