mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-08-11 17:32:11 +00:00
Fixed project list and recovery view to include unavailable projects
This commit is contained in:
parent
b3bbf4deb2
commit
aec8555b9a
@ -13,21 +13,26 @@
|
||||
<MudList T="Guid"
|
||||
Color="Color.Primary"
|
||||
Class="mb-1"
|
||||
SelectedValue="@(this.selectedBriefing?.BriefingId ?? Guid.Empty)"
|
||||
SelectedValue="@(this.selectedProject?.BriefingId ?? Guid.Empty)"
|
||||
SelectedValueChanged="@this.SelectBriefingAsync">
|
||||
@foreach (var briefing in this.briefings)
|
||||
@foreach (var project in this.projects)
|
||||
{
|
||||
<MudListItem T="Guid"
|
||||
Value="@briefing.BriefingId"
|
||||
Icon="@Icons.Material.Filled.Dashboard">
|
||||
<MudListItem T="Guid" Value="@project.BriefingId" Icon="@(project.IsAvailable ? Icons.Material.Filled.Dashboard : Icons.Material.Filled.WarningAmber)">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.body1">@(briefing.BriefingId == this.selectedBriefing?.BriefingId ? this.projectName : briefing.Name)</MudText>
|
||||
<MudText Typo="Typo.caption">@briefing.ModifiedAtUtc.ToLocalTime().ToString("g")</MudText>
|
||||
@if (this.IsGenerating(briefing.BriefingId))
|
||||
<MudText Typo="Typo.body1">@this.ProjectDisplayName(project)</MudText>
|
||||
<MudText Typo="Typo.caption">@project.ModifiedAtUtc.ToLocalTime().ToString("g")</MudText>
|
||||
@if (!project.IsAvailable)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Error">@this.ProjectStatusName(project.Status)</MudText>
|
||||
}
|
||||
@if (project.IsAvailable && this.IsGenerating(project.BriefingId))
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mt-1"/>
|
||||
}
|
||||
<MediaTranscriptionStatus Owner="@MediaImportOwner.ForVisualBriefing(briefing.BriefingId)" Compact="true"/>
|
||||
@if (project.IsAvailable)
|
||||
{
|
||||
<MediaTranscriptionStatus Owner="@MediaImportOwner.ForVisualBriefing(project.BriefingId)" Compact="true"/>
|
||||
}
|
||||
</MudStack>
|
||||
</MudListItem>
|
||||
}
|
||||
@ -41,7 +46,31 @@
|
||||
<MudDivider Style="height: 0.25ch; margin: 1rem 0;" Class="mt-6"/>
|
||||
|
||||
<main class="visual-briefing-main">
|
||||
@if (this.selectedBriefing is null)
|
||||
@if (this.selectedProject is not null && !this.selectedProject.IsAvailable)
|
||||
{
|
||||
<MudPaper Outlined="true" Class="pa-6">
|
||||
<MudStack Spacing="3">
|
||||
<MudText Typo="Typo.h4">@this.ProjectDisplayName(this.selectedProject)</MudText>
|
||||
<MudAlert Severity="Severity.Error" Variant="Variant.Outlined">
|
||||
@this.ProjectRecoveryMessage(this.selectedProject.Status)
|
||||
</MudAlert>
|
||||
<MudText Typo="Typo.body1">@T("AI Studio has left the project files unchanged. A future update may make this visual briefing accessible again.")</MudText>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Wrap="Wrap.Wrap">
|
||||
<MudText Typo="Typo.body2"><strong>@T("Project ID"):</strong> @this.selectedProject.BriefingId.ToString("D")</MudText>
|
||||
<MudCopyClipboardButton TooltipMessage="@T("Copy project ID")" StringContent="@this.selectedProject.BriefingId.ToString("D")"/>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.body2">
|
||||
@T("If you need help, report the problem and include the project ID.")
|
||||
<MudLink Href="https://github.com/MindWorkAI/AI-Studio" Target="_blank">@T("Report a problem?")</MudLink>
|
||||
</MudText>
|
||||
<MudStack Row="true" Spacing="1" Wrap="Wrap.Wrap">
|
||||
<MudButton Variant="Variant.Filled" StartIcon="@Icons.Material.Filled.FolderOpen" OnClick="@this.OpenSelectedProjectDirectoryAsync">@T("Open project folder")</MudButton>
|
||||
<MudButton Variant="Variant.Filled" StartIcon="@Icons.Material.Filled.DeleteForever" Color="Color.Error" OnClick="@this.DeleteAsync">@T("Delete")</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
}
|
||||
else if (this.selectedBriefing is null)
|
||||
{
|
||||
<MudPaper Outlined="true" Class="pa-6">
|
||||
<MudText Typo="Typo.h5">@T("Create or import a visual briefing to begin.")</MudText>
|
||||
|
||||
@ -150,12 +150,7 @@ public partial class VisualBriefingAssistant
|
||||
{
|
||||
var latest = await this.Store.LoadAsync(briefingId, cancellation.Token);
|
||||
if (latest is not null)
|
||||
this.briefings =
|
||||
[
|
||||
.. this.briefings
|
||||
.Select(briefing => briefing.BriefingId == briefingId ? latest : briefing)
|
||||
.OrderByDescending(briefing => briefing.ModifiedAtUtc)
|
||||
];
|
||||
this.UpdateProject(latest);
|
||||
}
|
||||
|
||||
this.Snackbar.Add(T("A new visual briefing version was created."), Severity.Success);
|
||||
@ -250,12 +245,7 @@ public partial class VisualBriefingAssistant
|
||||
{
|
||||
var latest = await this.Store.LoadAsync(briefingId, cancellation.Token);
|
||||
if (latest is not null)
|
||||
this.briefings =
|
||||
[
|
||||
.. this.briefings
|
||||
.Select(briefing => briefing.BriefingId == briefingId ? latest : briefing)
|
||||
.OrderByDescending(briefing => briefing.ModifiedAtUtc)
|
||||
];
|
||||
this.UpdateProject(latest);
|
||||
}
|
||||
|
||||
this.Snackbar.Add(T("The briefing was recompiled with the current AI Studio version."), Severity.Success);
|
||||
|
||||
@ -5,6 +5,7 @@ using AIStudio.Dialogs;
|
||||
using AIStudio.Provider;
|
||||
using AIStudio.Settings;
|
||||
using AIStudio.Tools.Media;
|
||||
using AIStudio.Tools.Rust;
|
||||
|
||||
using DialogOptions = AIStudio.Dialogs.DialogOptions;
|
||||
using ComponentKind = AIStudio.Tools.Components;
|
||||
@ -24,19 +25,21 @@ public partial class VisualBriefingAssistant
|
||||
/// </summary>
|
||||
private async Task ReloadListAsync(Guid? selectId = null)
|
||||
{
|
||||
this.briefings = await this.Store.ListAsync();
|
||||
this.projects = await this.Store.ListProjectsAsync();
|
||||
var id = selectId ??
|
||||
this.selectedBriefing?.BriefingId ??
|
||||
this.selectedProject?.BriefingId ??
|
||||
this.Store.LastSelectedBriefingId ??
|
||||
this.briefings.FirstOrDefault()?.BriefingId;
|
||||
this.projects.FirstOrDefault()?.BriefingId;
|
||||
|
||||
var selected = id is null
|
||||
? null
|
||||
: this.briefings.FirstOrDefault(briefing => briefing.BriefingId == id);
|
||||
: this.projects.FirstOrDefault(project => project.BriefingId == id);
|
||||
|
||||
selected ??= this.briefings.FirstOrDefault();
|
||||
selected ??= this.projects.FirstOrDefault();
|
||||
if (selected is not null)
|
||||
await this.ApplySelectedBriefingAsync(selected);
|
||||
await this.ApplySelectedProjectAsync(selected);
|
||||
else
|
||||
this.ClearSelectedProject();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -44,15 +47,15 @@ public partial class VisualBriefingAssistant
|
||||
/// </summary>
|
||||
private async Task SelectBriefingAsync(Guid briefingId)
|
||||
{
|
||||
if (this.selectedBriefing?.BriefingId == briefingId)
|
||||
if (this.selectedProject?.BriefingId == briefingId)
|
||||
return;
|
||||
|
||||
if (this.selectedBriefing is not null)
|
||||
await this.SaveCurrentAsync();
|
||||
|
||||
var briefing = this.briefings.FirstOrDefault(candidate => candidate.BriefingId == briefingId);
|
||||
if (briefing is not null)
|
||||
await this.ApplySelectedBriefingAsync(briefing);
|
||||
var project = this.projects.FirstOrDefault(candidate => candidate.BriefingId == briefingId);
|
||||
if (project is not null)
|
||||
await this.ApplySelectedProjectAsync(project);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -116,29 +119,70 @@ public partial class VisualBriefingAssistant
|
||||
/// </summary>
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (this.selectedBriefing is null)
|
||||
if (this.selectedProject is null)
|
||||
return;
|
||||
|
||||
var parameters = new DialogParameters<ConfirmDialog>
|
||||
var parameters = new DialogParameters<ConfirmDialog>();
|
||||
if (this.selectedProject.IsAvailable)
|
||||
parameters.Add(dialog => dialog.Message, string.Format(T("Permanently delete the visual briefing '{0}' and all of its versions and transcripts?"), this.selectedProject.Name));
|
||||
else
|
||||
{
|
||||
{ dialog => dialog.Message, string.Format(T("Permanently delete the visual briefing '{0}' and all of its versions and transcripts?"), this.selectedBriefing.Name) },
|
||||
};
|
||||
var reportingWarning = T("This visual briefing cannot currently be opened. Consider reporting the problem in the [MindWork AI Studio issue tracker](https://github.com/MindWorkAI/AI-Studio), because a future update may make the briefing accessible again.");
|
||||
var deletionWarning = T("Permanently delete this visual briefing and all of its versions and transcripts?");
|
||||
parameters.Add(dialog => dialog.MarkdownBody, $"{reportingWarning}\n\n{deletionWarning}");
|
||||
}
|
||||
|
||||
var reference = await this.DialogService.ShowAsync<ConfirmDialog>(T("Delete visual briefing permanently"), parameters, DialogOptions.FULLSCREEN);
|
||||
var result = await reference.Result;
|
||||
if (result is null || result.Canceled)
|
||||
return;
|
||||
|
||||
var id = this.selectedBriefing.BriefingId;
|
||||
var id = this.selectedProject.BriefingId;
|
||||
this.MediaTranscriptionService.ClearOwnerState(MediaImportOwner.ForVisualBriefing(id));
|
||||
await this.Store.DeleteAsync(id);
|
||||
await this.Store.ForgetSelectionAsync(id);
|
||||
this.selectedBriefing = null;
|
||||
this.previewUrl = string.Empty;
|
||||
this.ClearSelectedProject();
|
||||
|
||||
await this.ReloadListAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the selected project directory without attempting to read or repair its contents.
|
||||
/// </summary>
|
||||
private async Task OpenSelectedProjectDirectoryAsync()
|
||||
{
|
||||
if (this.selectedProject is null)
|
||||
return;
|
||||
|
||||
var path = await this.Store.GetProjectDirectoryPathAsync(this.selectedProject.BriefingId);
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
this.Snackbar.Add(T("The visual briefing project folder is not available."), Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
OpenPathResponse response;
|
||||
try
|
||||
{
|
||||
response = await this.RustService.TryOpenPathInRuntimeFileManager(path);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
this.Logger.LogWarning(exception, "Could not open the visual briefing project folder. BriefingId={BriefingId}", this.selectedProject.BriefingId);
|
||||
this.Snackbar.Add(T("Could not open the visual briefing project folder."), Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.Success)
|
||||
{
|
||||
this.Snackbar.Add(T("Opened the visual briefing project folder."), Severity.Success);
|
||||
return;
|
||||
}
|
||||
|
||||
var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue;
|
||||
this.Snackbar.Add(string.Format(T("Could not open the visual briefing project folder: {0}"), issue), Severity.Error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>SaveCurrentAsync</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
@ -188,6 +232,7 @@ public partial class VisualBriefingAssistant
|
||||
private async Task ApplySelectedBriefingAsync(VisualBriefingManifest briefing)
|
||||
{
|
||||
await this.Store.RememberSelectionAsync(briefing.BriefingId);
|
||||
this.selectedProject = VisualBriefingProjectEntry.FromManifest(briefing);
|
||||
this.selectedBriefing = briefing;
|
||||
var resumableBuilds = await this.Store.ListBuildsAsync(briefing.BriefingId);
|
||||
var persistedDiagnostics = resumableBuilds.FirstOrDefault() is { } latestPersistedBuild
|
||||
@ -252,6 +297,80 @@ public partial class VisualBriefingAssistant
|
||||
this.lastPersistedState = this.BuildPersistenceFingerprint();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies either a normal editor project or a content-free recovery entry.
|
||||
/// </summary>
|
||||
private async Task ApplySelectedProjectAsync(VisualBriefingProjectEntry project)
|
||||
{
|
||||
if (project.IsAvailable)
|
||||
{
|
||||
await this.ApplySelectedBriefingAsync(project.Manifest!);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.Store.RememberSelectionAsync(project.BriefingId);
|
||||
this.ClearSelectedProject();
|
||||
this.selectedProject = project;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears editor-only state so an unavailable project cannot trigger saves or background work.
|
||||
/// </summary>
|
||||
private void ClearSelectedProject()
|
||||
{
|
||||
this.selectedProject = null;
|
||||
this.selectedBriefing = null;
|
||||
this.sourceMaterial = [];
|
||||
this.visualAssets = [];
|
||||
this.selectedRevisionId = Guid.Empty;
|
||||
this.previewUrl = string.Empty;
|
||||
this.latestBuild = null;
|
||||
this.lastBuildDiagnostics = null;
|
||||
this.reusableContentBuildId = null;
|
||||
this.lastPersistedState = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces an available list entry after a background operation updates its manifest.
|
||||
/// </summary>
|
||||
private void UpdateProject(VisualBriefingManifest briefing)
|
||||
{
|
||||
var updated = VisualBriefingProjectEntry.FromManifest(briefing);
|
||||
this.projects = [.. this.projects.Select(project => project.BriefingId == briefing.BriefingId ? updated : project).OrderByDescending(project => project.ModifiedAtUtc)];
|
||||
|
||||
if (this.selectedProject?.BriefingId == briefing.BriefingId)
|
||||
this.selectedProject = updated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a safe list and recovery-view title.
|
||||
/// </summary>
|
||||
private string ProjectDisplayName(VisualBriefingProjectEntry project)
|
||||
{
|
||||
if (project.BriefingId == this.selectedBriefing?.BriefingId)
|
||||
return this.projectName;
|
||||
|
||||
return string.IsNullOrWhiteSpace(project.Name) ? T("Unavailable visual briefing") : project.Name;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the concise project-list status.
|
||||
/// </summary>
|
||||
private string ProjectStatusName(VisualBriefingProjectLoadStatus status) => status switch
|
||||
{
|
||||
VisualBriefingProjectLoadStatus.NEWER_VERSION => T("Requires a newer AI Studio version"),
|
||||
_ => T("Cannot be opened"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the recovery explanation for an unavailable project.
|
||||
/// </summary>
|
||||
private string ProjectRecoveryMessage(VisualBriefingProjectLoadStatus status) => status switch
|
||||
{
|
||||
VisualBriefingProjectLoadStatus.NEWER_VERSION => T("This visual briefing was created by a newer AI Studio version and cannot be opened by this version."),
|
||||
_ => T("AI Studio cannot read this visual briefing. Its files may be incompatible or damaged."),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Defines <c>ProtectionLevelName</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
|
||||
@ -135,12 +135,7 @@ public partial class VisualBriefingAssistant
|
||||
var latest = await this.Store.LoadAsync(briefingId);
|
||||
if (latest is not null)
|
||||
{
|
||||
this.briefings =
|
||||
[
|
||||
.. this.briefings
|
||||
.Select(briefing => briefing.BriefingId == briefingId ? latest : briefing)
|
||||
.OrderByDescending(briefing => briefing.ModifiedAtUtc)
|
||||
];
|
||||
this.UpdateProject(latest);
|
||||
|
||||
if (this.selectedBriefing?.BriefingId == briefingId)
|
||||
await this.ApplySelectedBriefingAsync(latest);
|
||||
|
||||
@ -91,8 +91,11 @@ public partial class VisualBriefingAssistant : MSGComponentBase
|
||||
/// <summary>Stops the background source-status monitor.</summary>
|
||||
private readonly CancellationTokenSource sourceMonitorCancellation = new();
|
||||
|
||||
/// <summary>Stores projects ordered by most recent modification.</summary>
|
||||
private IReadOnlyList<VisualBriefingManifest> briefings = [];
|
||||
/// <summary>Stores available and recoverable projects ordered by most recent modification.</summary>
|
||||
private IReadOnlyList<VisualBriefingProjectEntry> projects = [];
|
||||
|
||||
/// <summary>Stores the project entry currently selected in the list.</summary>
|
||||
private VisualBriefingProjectEntry? selectedProject;
|
||||
|
||||
/// <summary>Stores the project currently displayed by the editor.</summary>
|
||||
private VisualBriefingManifest? selectedBriefing;
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
namespace AIStudio.Assistants.VisualBriefing;
|
||||
|
||||
/// <summary>
|
||||
/// Provides safe list metadata even when the persisted manifest cannot be deserialized.
|
||||
/// </summary>
|
||||
internal sealed record VisualBriefingProjectEntry(Guid BriefingId, string Name, DateTimeOffset ModifiedAtUtc, VisualBriefingProjectLoadStatus Status, VisualBriefingManifest? Manifest)
|
||||
{
|
||||
/// <summary>Gets whether the project can be opened normally.</summary>
|
||||
public bool IsAvailable => this.Status is VisualBriefingProjectLoadStatus.AVAILABLE && this.Manifest is not null;
|
||||
|
||||
/// <summary>Creates an available project entry from a validated manifest.</summary>
|
||||
public static VisualBriefingProjectEntry FromManifest(VisualBriefingManifest manifest) => new(manifest.BriefingId, manifest.Name, manifest.ModifiedAtUtc, VisualBriefingProjectLoadStatus.AVAILABLE, manifest);
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
namespace AIStudio.Assistants.VisualBriefing;
|
||||
|
||||
/// <summary>
|
||||
/// Describes whether a persisted visual briefing can be opened by this AI Studio version.
|
||||
/// </summary>
|
||||
internal enum VisualBriefingProjectLoadStatus
|
||||
{
|
||||
AVAILABLE,
|
||||
NEWER_VERSION,
|
||||
UNAVAILABLE,
|
||||
}
|
||||
@ -121,21 +121,142 @@ public sealed partial class VisualBriefingStore
|
||||
/// 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<VisualBriefingManifest> manifests = [];
|
||||
List<VisualBriefingProjectEntry> projects = [];
|
||||
foreach (var directory in Directory.EnumerateDirectories(this.RootDirectory))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!Guid.TryParse(Path.GetFileName(directory), out var briefingId))
|
||||
continue;
|
||||
|
||||
var manifest = await this.LoadAsync(briefingId, token);
|
||||
if (manifest is not null)
|
||||
manifests.Add(manifest);
|
||||
projects.Add(await this.LoadProjectEntryAsync(briefingId, directory, token));
|
||||
}
|
||||
|
||||
return manifests.OrderByDescending(manifest => manifest.ModifiedAtUtc).ToArray();
|
||||
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>
|
||||
@ -161,7 +282,7 @@ public sealed partial class VisualBriefingStore
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
logger.LogWarning(
|
||||
new EventId((int)VisualBriefingLogEventId.STORE_REJECTED, VisualBriefingLogEventId.STORE_REJECTED.ToString()),
|
||||
new EventId((int)VisualBriefingLogEventId.STORE_REJECTED, nameof(VisualBriefingLogEventId.STORE_REJECTED)),
|
||||
exception,
|
||||
"Could not load visual briefing manifest. BriefingId={BriefingId} ExceptionType={ExceptionType}",
|
||||
briefingId,
|
||||
@ -210,6 +331,7 @@ public sealed partial class VisualBriefingStore
|
||||
ModifiedAtUtc = now,
|
||||
Settings = settings,
|
||||
};
|
||||
|
||||
await this.StoreManifestAtomicAsync(manifest, token);
|
||||
return manifest;
|
||||
}
|
||||
@ -237,13 +359,7 @@ public sealed partial class VisualBriefingStore
|
||||
/// <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)
|
||||
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 =>
|
||||
{
|
||||
@ -309,16 +425,13 @@ public sealed partial class VisualBriefingStore
|
||||
/// <summary>
|
||||
/// Defines <c>LoadRequiredWithoutInitializeAsync</c> for the visual briefing feature.
|
||||
/// </summary>
|
||||
private async Task<VisualBriefingManifest> LoadRequiredWithoutInitializeAsync(
|
||||
Guid briefingId,
|
||||
CancellationToken token)
|
||||
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.");
|
||||
return await this.LoadWithoutInitializeAsync(briefingId, token) ?? throw new InvalidDataException("The visual briefing manifest is invalid.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user