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); } }