using System.Text.Json;
namespace AIStudio.Assistants.BeyondFactsLab;
public sealed partial class BeyondFactsLabStore
{
///
/// Gets the preparation the person selected last, or null when there is none or it does not exist anymore.
///
public Guid? LastSelectedProjectId { get; private set; }
///
/// Lists every preparation, also those this version cannot open, the one changed last first.
///
/// The cancellation token.
/// The preparations.
public async Task> ListProjectsAsync(CancellationToken token = default)
{
await this.InitializeAsync(token);
List projects = [];
foreach (var directory in Directory.EnumerateDirectories(this.RootDirectory))
{
token.ThrowIfCancellationRequested();
if (!Guid.TryParseExact(Path.GetFileName(directory), "D", out var projectId))
continue;
projects.Add(await this.LoadProjectEntryAsync(projectId, directory, token));
}
return [.. projects.OrderByDescending(project => project.ModifiedAtUtc)];
}
///
/// Loads a preparation.
///
/// The ID of the preparation.
/// The cancellation token.
/// The preparation, or null when it does not exist or this version cannot open it.
public async Task LoadAsync(Guid projectId, CancellationToken token = default)
{
await this.InitializeAsync(token);
try
{
return await this.LoadWithoutInitializeAsync(projectId, token);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException)
{
this.LogUnavailableProject(projectId, exception);
return null;
}
}
///
/// Creates a preparation.
///
/// The name the person gave it.
/// The cancellation token.
/// The new preparation.
/// The name is empty.
public async Task CreateAsync(string name, CancellationToken token = default)
{
var sanitizedName = RequireName(name);
await this.InitializeAsync(token);
var projectId = Guid.NewGuid();
var gate = this.GetLock(projectId);
await gate.WaitAsync(token);
try
{
var directory = this.ProjectDirectory(projectId);
if (Directory.Exists(directory))
throw new IOException($"A preparation with the ID '{projectId}' exists already.");
var now = DateTimeOffset.UtcNow;
var project = new BflProject
{
ProjectId = projectId,
Name = sanitizedName,
CreatedAtUtc = now,
ModifiedAtUtc = now,
};
await this.StoreProjectAsync(project, token);
return project;
}
finally
{
gate.Release();
}
}
///
/// Renames a preparation.
///
/// The ID of the preparation.
/// The new name.
/// The cancellation token.
/// The renamed preparation.
/// The name is empty.
public async Task RenameAsync(Guid projectId, string name, CancellationToken token = default)
{
var sanitizedName = RequireName(name);
return await this.MutateProjectAsync(projectId, project => project.Name = sanitizedName, token);
}
///
/// Marks the event of a preparation as having taken place, or takes that back.
///
/// The ID of the preparation.
/// When the event took place, or null when it has not.
/// The cancellation token.
/// The changed preparation.
public async Task SetHappenedAsync(Guid projectId, DateTimeOffset? happenedAtUtc, CancellationToken token = default)
{
return await this.MutateProjectAsync(projectId, project => project.HappenedAtUtc = happenedAtUtc?.ToUniversalTime(), token);
}
///
/// Deletes a preparation with everything it holds, also when this version cannot open it.
///
/// The ID of the preparation.
/// The cancellation token.
public async Task DeleteAsync(Guid projectId, CancellationToken token = default)
{
await this.InitializeAsync(token);
var gate = this.GetLock(projectId);
await gate.WaitAsync(token);
try
{
var directory = this.ProjectDirectory(projectId);
if (Directory.Exists(directory))
Directory.Delete(directory, recursive: true);
}
finally
{
gate.Release();
this.ForgetLock(projectId);
}
await this.ForgetSelectionAsync(projectId, token);
}
///
/// Remembers the preparation the person selected, for the next time the lab opens.
///
/// The ID of the preparation.
/// The cancellation token.
public async Task RememberSelectionAsync(Guid projectId, CancellationToken token = default)
{
await this.InitializeAsync(token);
if (this.LastSelectedProjectId == projectId)
return;
await this.selectionLock.WaitAsync(token);
try
{
this.LastSelectedProjectId = projectId;
await WriteTextAtomicAsync(this.SelectionPath(), JsonSerializer.Serialize(projectId, JSON_OPTIONS), token);
}
finally
{
this.selectionLock.Release();
}
}
///
/// Forgets the selection when it names the given preparation.
///
/// The ID of the preparation.
/// The cancellation token.
private async Task ForgetSelectionAsync(Guid projectId, CancellationToken token)
{
if (this.LastSelectedProjectId != projectId)
return;
await this.selectionLock.WaitAsync(token);
try
{
if (this.LastSelectedProjectId != projectId)
return;
this.LastSelectedProjectId = null;
await WriteTextAtomicAsync(this.SelectionPath(), JsonSerializer.Serialize(null, JSON_OPTIONS), token);
}
finally
{
this.selectionLock.Release();
}
}
///
/// Reads the preparation selected last, when it still exists.
///
/// The cancellation token.
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, JSON_OPTIONS);
this.LastSelectedProjectId = selected is not null && Directory.Exists(this.ProjectDirectory(selected.Value)) ? selected : null;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException)
{
logger.LogWarning(exception, "Could not read the preparation of the BeyondFacts Lab selected last.");
this.LastSelectedProjectId = null;
}
}
///
/// Loads a preparation for the list, or an entry which says why it cannot be opened.
///
/// The ID of the preparation.
/// The folder of the preparation.
/// The cancellation token.
private async Task LoadProjectEntryAsync(Guid projectId, string directory, CancellationToken token)
{
var path = this.ProjectPath(projectId);
var fallbackModifiedAtUtc = LastWriteTimeUtc(path, directory);
if (!File.Exists(path))
return new(projectId, string.Empty, fallbackModifiedAtUtc, BflProjectLoadStatus.UNAVAILABLE, null);
string json;
try
{
json = await File.ReadAllTextAsync(path, token);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
this.LogUnavailableProject(projectId, exception);
return new(projectId, string.Empty, fallbackModifiedAtUtc, BflProjectLoadStatus.UNAVAILABLE, null);
}
try
{
var project = JsonSerializer.Deserialize(json, JSON_OPTIONS);
if (project is not null && IsValidProject(project, projectId))
return BflProjectEntry.FromProject(project);
}
catch (JsonException exception)
{
this.LogUnavailableProject(projectId, exception);
}
return ReadRecoveryEntry(projectId, json, fallbackModifiedAtUtc);
}
///
/// Reads what the list can show about a preparation this version cannot open: its name, when it changed,
/// and whether a newer version stored it.
///
/// The ID of the preparation.
/// The content of its file.
/// When the file changed, for files without a readable date.
private static BflProjectEntry ReadRecoveryEntry(Guid projectId, string json, DateTimeOffset fallbackModifiedAtUtc)
{
try
{
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
if (root.ValueKind is not JsonValueKind.Object)
return new(projectId, string.Empty, fallbackModifiedAtUtc, BflProjectLoadStatus.UNAVAILABLE, null);
var name = root.TryGetProperty("name", out var nameElement) && nameElement.ValueKind is JsonValueKind.String ? SanitizeName(nameElement.GetString()) : string.Empty;
var modifiedAtUtc = root.TryGetProperty("modifiedAtUtc", out var modifiedElement) && modifiedElement.ValueKind is JsonValueKind.String && modifiedElement.TryGetDateTimeOffset(out var parsedModifiedAtUtc)
? parsedModifiedAtUtc
: fallbackModifiedAtUtc;
var isNewer = root.TryGetProperty("schemaVersion", out var versionElement) && versionElement.ValueKind is JsonValueKind.Number && versionElement.TryGetInt32(out var schemaVersion) && schemaVersion > BflProject.CURRENT_SCHEMA_VERSION;
return new(projectId, name, modifiedAtUtc, isNewer ? BflProjectLoadStatus.NEWER_VERSION : BflProjectLoadStatus.UNAVAILABLE, null);
}
catch (JsonException)
{
return new(projectId, string.Empty, fallbackModifiedAtUtc, BflProjectLoadStatus.UNAVAILABLE, null);
}
}
///
/// Gets when the file of a preparation, or else its folder, changed last.
///
/// The file of the preparation.
/// The folder of the preparation.
private static DateTimeOffset LastWriteTimeUtc(string projectPath, string directory)
{
try
{
var timestamp = File.Exists(projectPath) ? File.GetLastWriteTimeUtc(projectPath) : Directory.GetLastWriteTimeUtc(directory);
return new DateTimeOffset(timestamp);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return DateTimeOffset.UnixEpoch;
}
}
///
/// Remembers the step the person looked at last, without counting it as a change of the preparation.
///
/// The ID of the preparation.
/// The step.
/// The cancellation token.
/// The preparation.
public async Task SetCurrentStepAsync(Guid projectId, BflStep step, CancellationToken token = default)
{
return await this.MutateProjectAsync(projectId, project => project.CurrentStep = step, token, isChange: false);
}
///
/// Changes a preparation under its lock and stores it.
///
/// The ID of the preparation.
/// The change.
/// The cancellation token.
/// Whether the list shows the preparation as changed now.
/// The changed preparation.
/// The preparation does not exist.
/// This version cannot open the preparation, so it does not write it either.
private async Task MutateProjectAsync(Guid projectId, Action mutation, CancellationToken token, bool isChange = true)
{
await this.InitializeAsync(token);
var gate = this.GetLock(projectId);
await gate.WaitAsync(token);
try
{
var project = await this.LoadRequiredWithoutInitializeAsync(projectId, token);
mutation(project);
if (isChange)
project.ModifiedAtUtc = DateTimeOffset.UtcNow;
await this.StoreProjectAsync(project, token);
return project;
}
finally
{
gate.Release();
}
}
///
/// Reads a preparation which has to exist and has to be one this version can open, without the first start of the store.
///
/// The ID of the preparation.
/// The cancellation token.
/// The preparation.
/// The preparation does not exist.
/// This version cannot open the preparation.
private async Task LoadRequiredWithoutInitializeAsync(Guid projectId, CancellationToken token)
{
var path = this.ProjectPath(projectId);
if (!File.Exists(path))
throw new FileNotFoundException("The preparation does not exist.", path);
BflProject? project;
try
{
project = await this.LoadWithoutInitializeAsync(projectId, token);
}
catch (JsonException exception)
{
throw new InvalidDataException("This version of AI Studio cannot open the preparation.", exception);
}
return project ?? throw new InvalidDataException("This version of AI Studio cannot open the preparation.");
}
///
/// Reads a preparation without the first start of the store.
///
/// The ID of the preparation.
/// The cancellation token.
/// The preparation, or null when it does not exist or is not valid.
private async Task LoadWithoutInitializeAsync(Guid projectId, CancellationToken token)
{
var path = this.ProjectPath(projectId);
if (!File.Exists(path))
return null;
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, true);
var project = await JsonSerializer.DeserializeAsync(stream, JSON_OPTIONS, token);
return project is not null && IsValidProject(project, projectId) ? project : null;
}
///
/// Writes a preparation in the form of this version.
///
/// The preparation.
/// The cancellation token.
private async Task StoreProjectAsync(BflProject project, CancellationToken token)
{
project.SchemaVersion = BflProject.CURRENT_SCHEMA_VERSION;
var json = JsonSerializer.Serialize(project, JSON_OPTIONS);
await WriteTextAtomicAsync(this.ProjectPath(project.ProjectId), json, token);
}
///
/// Checks a preparation which was read from a file.
///
/// The preparation.
/// The ID its folder names.
private static bool IsValidProject(BflProject project, Guid expectedProjectId) =>
project.SchemaVersion is >= 1 and <= BflProject.CURRENT_SCHEMA_VERSION &&
project.ProjectId != Guid.Empty &&
project.ProjectId == expectedProjectId &&
!string.IsNullOrWhiteSpace(project.Name) &&
Enum.IsDefined(project.CurrentStep);
///
/// Cleans a name and refuses one which is empty afterward.
///
/// The name as entered.
/// The cleaned name.
/// The name is empty.
private static string RequireName(string name)
{
var sanitizedName = SanitizeName(name);
if (sanitizedName.Length == 0)
throw new ArgumentException("A preparation needs a name.", nameof(name));
return sanitizedName;
}
///
/// Logs why a preparation cannot be opened.
///
/// The ID of the preparation.
/// What went wrong.
private void LogUnavailableProject(Guid projectId, Exception exception)
{
logger.LogWarning(exception, "Could not open the preparation '{ProjectId}' of the BeyondFacts Lab.", projectId);
}
}