using AIStudio.Chat;
using AIStudio.Tools.Rust;
namespace AIStudio.Assistants.VisualBriefing;
public sealed partial class VisualBriefingStore
{
///
/// Defines RelinkSourceAsync for the visual briefing feature.
///
public async Task RelinkSourceAsync(Guid briefingId, Guid sourceId, string newPath, CancellationToken token = default)
{
if (!File.Exists(newPath))
throw new FileNotFoundException("The replacement source is not reachable.", newPath);
if (!IsSupportedSourcePath(newPath))
throw new InvalidDataException("The replacement file type is not supported as briefing source material.");
await this.MutateManifestAsync(briefingId, manifest =>
{
var source = manifest.Sources.FirstOrDefault(candidate => candidate.SourceId == sourceId)
?? throw new InvalidOperationException("The source does not exist in this briefing.");
if (source.Kind is VisualBriefingSourceKind.VISUAL_ASSET &&
!FileTypes.IsAllowedPath(newPath, FileTypes.VISUAL_BRIEFING_IMAGE))
throw new InvalidDataException("Visual assets must be PNG, JPEG, or WebP files.");
var wasMedia = source.IsMedia;
ApplyFileSnapshot(source, newPath);
if (source.IsMedia)
source.TranscriptStatus = VisualBriefingTranscriptStatus.OUTDATED;
else
{
source.TranscriptStatus = VisualBriefingTranscriptStatus.NOT_REQUIRED;
if (wasMedia)
TryDeleteFile(this.TranscriptPath(briefingId, source.SourceId));
}
manifest.ModifiedAtUtc = DateTimeOffset.UtcNow;
}, token);
}
///
/// Defines RemoveSourceAsync for the visual briefing feature.
///
public async Task RemoveSourceAsync(Guid briefingId, Guid sourceId, CancellationToken token = default)
{
await this.MutateManifestAsync(briefingId, manifest =>
{
var source = manifest.Sources.FirstOrDefault(candidate => candidate.SourceId == sourceId);
if (source is null)
return;
manifest.Sources.Remove(source);
TryDeleteFile(this.TranscriptPath(briefingId, source.SourceId));
manifest.ModifiedAtUtc = DateTimeOffset.UtcNow;
}, token);
}
///
/// Defines FindSourceIdByPathAsync for the visual briefing feature.
///
public async Task FindSourceIdByPathAsync(Guid briefingId, string path, CancellationToken token = default)
{
var manifest = await this.LoadAsync(briefingId, token);
if (manifest is null)
return null;
var fullPath = Path.GetFullPath(path);
return manifest.Sources.FirstOrDefault(source =>
PathComparer().Equals(Path.GetFullPath(source.Path), fullPath))?.SourceId;
}
///
/// Defines SetTranscriptCurrentAsync for the visual briefing feature.
///
public async Task SetTranscriptCurrentAsync(Guid briefingId, Guid sourceId, string transcript, CancellationToken token = default)
{
var gate = this.GetLock(briefingId);
await gate.WaitAsync(token);
try
{
var manifest = await this.LoadRequiredWithoutInitializeAsync(briefingId, token);
var source = manifest.Sources.FirstOrDefault(candidate => candidate.SourceId == sourceId)
?? throw new InvalidOperationException("The media source does not exist in this briefing.");
var transcriptPath = this.TranscriptPath(briefingId, source.SourceId);
await WriteTextAtomicAsync(transcriptPath, transcript, token);
source.TranscriptStatus = VisualBriefingTranscriptStatus.CURRENT;
ApplyFileSnapshot(source, source.Path);
manifest.ModifiedAtUtc = DateTimeOffset.UtcNow;
await this.StoreManifestAtomicAsync(manifest, token);
}
finally
{
gate.Release();
}
}
///
/// Defines ReadTranscriptAsync for the visual briefing feature.
///
public async Task ReadTranscriptAsync(Guid briefingId, Guid sourceId, CancellationToken token = default)
{
var path = this.TranscriptPath(briefingId, sourceId);
return File.Exists(path) ? await File.ReadAllTextAsync(path, token) : null;
}
///
/// Defines GetTranscriptPath for the visual briefing feature.
///
public string GetTranscriptPath(Guid briefingId, Guid sourceId) => this.TranscriptPath(briefingId, sourceId);
///
/// Defines RefreshSourceStatuses for the visual briefing feature.
///
private static void RefreshSourceStatuses(VisualBriefingManifest manifest)
{
foreach (var source in manifest.Sources)
{
if (!File.Exists(source.Path))
{
source.Status = VisualBriefingSourceStatus.UNREACHABLE;
continue;
}
var info = new FileInfo(source.Path);
var changed = info.Length != source.Size || info.LastWriteTimeUtc != source.LastWriteTimeUtc.UtcDateTime;
source.Status = changed
? source.IsMedia ? VisualBriefingSourceStatus.TRANSCRIPT_OUTDATED : VisualBriefingSourceStatus.CHANGED
: source.IsMedia && source.TranscriptStatus is not VisualBriefingTranscriptStatus.CURRENT
? VisualBriefingSourceStatus.TRANSCRIPT_OUTDATED
: VisualBriefingSourceStatus.UNCHANGED;
}
}
///
/// Defines MergeSources for the visual briefing feature.
///
private static List MergeSources(
IReadOnlyCollection existing,
IEnumerable<(string Path, VisualBriefingSourceKind Kind)> updated)
{
List result = [];
foreach (var (path, kind) in updated.DistinctBy(item => Path.GetFullPath(item.Path), PathComparer()))
{
var fullPath = Path.GetFullPath(path);
if (kind is VisualBriefingSourceKind.VISUAL_ASSET &&
!FileTypes.IsAllowedPath(fullPath, FileTypes.VISUAL_BRIEFING_IMAGE))
throw new InvalidDataException("Visual assets must be PNG, JPEG, or WebP files.");
var source = existing.FirstOrDefault(candidate =>
candidate.Kind == kind && PathComparer().Equals(Path.GetFullPath(candidate.Path), fullPath));
if (!File.Exists(fullPath))
{
if (source is not null)
result.Add(source);
continue;
}
if (!IsSupportedSourcePath(fullPath))
throw new InvalidDataException($"The source file type '{Path.GetExtension(fullPath)}' is not supported.");
if (source is null)
{
source = new VisualBriefingSource
{
SourceId = Guid.NewGuid(),
Kind = kind,
AssetId = kind is VisualBriefingSourceKind.VISUAL_ASSET
? NextAssetId(existing.Concat(result))
: string.Empty,
IsMedia = FileTypes.IsAllowedPath(fullPath, FileTypes.AUDIO, FileTypes.VIDEO),
};
ApplyFileSnapshot(source, fullPath);
source.TranscriptStatus = source.IsMedia
? VisualBriefingTranscriptStatus.MISSING
: VisualBriefingTranscriptStatus.NOT_REQUIRED;
}
result.Add(source);
}
return result;
}
///
/// Picks the asset handle for a new visual asset. Asset IDs reach the model, which cannot
/// reproduce opaque identifiers reliably, so they stay short. The smallest free number is taken
/// instead of renumbering, so removing one asset never changes the handle of another.
///
/// The sources that already carry an asset handle.
/// The new asset handle.
private static string NextAssetId(IEnumerable sources)
{
var used = sources
.Select(source => source.AssetId)
.Where(assetId => !string.IsNullOrWhiteSpace(assetId))
.ToHashSet(StringComparer.Ordinal);
var number = 1;
while (used.Contains($"a{number}"))
number++;
return $"a{number}";
}
///
/// Defines ApplyFileSnapshot for the visual briefing feature.
///
private static void ApplyFileSnapshot(VisualBriefingSource source, string path)
{
var info = new FileInfo(path);
source.Path = info.FullName;
source.Size = info.Length;
source.LastWriteTimeUtc = info.LastWriteTimeUtc;
source.IsMedia = FileTypes.IsAllowedPath(info.FullName, FileTypes.AUDIO, FileTypes.VIDEO);
source.Status = VisualBriefingSourceStatus.UNCHANGED;
}
///
/// Returns whether an asset identifier is safe for JSON paths, bindings, and HTML attributes.
///
/// The identifier to validate.
/// for a canonical asset identifier.
private static bool IsValidAssetId(string assetId) =>
assetId.StartsWith('a') &&
assetId.Length is > 1 and <= 16 &&
assetId[1..].All(char.IsAsciiDigit);
///
/// Defines IsSupportedSourcePath for the visual briefing feature.
///
private static bool IsSupportedSourcePath(string path) =>
FileAttachment.FromPath(path).IsValid ||
FileTypes.IsAllowedPath(path, FileTypes.AUDIO, FileTypes.VIDEO);
}