using System.Text.Json; namespace AIStudio.Assistants.BeyondFactsLab; public sealed partial class BeyondFactsLabStore { /// /// The folder below the research of the step "Strategy" which holds the research on single people. /// private const string PERSON_RESEARCH_DIRECTORY_NAME = "participants"; /// /// The file below the research of the step "Strategy" which holds whom of the list of participants the lab /// researches in depth. /// private const string PARTICIPANT_SELECTION_FILE_NAME = "participant-selection.json"; /// /// Loads whom of the list of participants of a preparation the lab researches in depth. /// /// The ID of the preparation. /// Cancels the loading. /// The selection, or null when nobody chose yet. /// The selection is damaged, or a newer version of AI Studio stored it. public async Task LoadParticipantSelectionAsync(Guid projectId, CancellationToken token = default) { await this.InitializeAsync(token); return await this.LoadParticipantSelectionWithoutInitializeAsync(projectId, token); } /// /// Stores whom of the list of participants of a preparation the lab researches in depth, in place of the earlier /// selection. /// /// The ID of the preparation. /// The selection. /// Cancels the storing. /// The preparation, with the time of its last change. /// The preparation does not exist. /// The earlier selection is damaged, or a newer version of AI Studio stored it; it stays as it is. public async Task SaveParticipantSelectionAsync(Guid projectId, BflParticipantSelection selection, CancellationToken token = default) { await this.InitializeAsync(token); var gate = this.GetLock(projectId); await gate.WaitAsync(token); try { var project = await this.LoadRequiredWithoutInitializeAsync(projectId, token); // A selection which a newer version stored, or which is damaged, stays as it is: await this.LoadParticipantSelectionWithoutInitializeAsync(projectId, token); selection.SchemaVersion = BflParticipantSelection.CURRENT_SCHEMA_VERSION; await WriteTextAtomicAsync(this.ParticipantSelectionPath(projectId), JsonSerializer.Serialize(selection, JSON_OPTIONS), token); project.ModifiedAtUtc = DateTimeOffset.UtcNow; await this.StoreProjectAsync(project, token); return project; } finally { gate.Release(); } } /// /// Loads the research on one person of a preparation. /// /// The ID of the preparation. /// The name of the person. /// Cancels the loading. /// The research, or null when the person was not researched yet. /// The research is damaged, or a newer version of AI Studio stored it. public async Task LoadPersonResearchAsync(Guid projectId, string name, CancellationToken token = default) { await this.InitializeAsync(token); return await this.LoadPersonResearchWithoutInitializeAsync(this.PersonResearchPath(projectId, BflPersonId.Of(name)), token); } /// /// Loads the research on every person of a preparation, including people who are no longer on the list of /// participants. A research which cannot be opened is left out and stays as it is. /// /// The ID of the preparation. /// Cancels the loading. /// The research on the people, in no particular order. public async Task> LoadAllPersonResearchAsync(Guid projectId, CancellationToken token = default) { await this.InitializeAsync(token); var directory = this.PersonResearchDirectory(projectId); if (!Directory.Exists(directory)) return []; var research = new List(); foreach (var path in Directory.EnumerateFiles(directory, "*.json")) { // Only the files of people count, named by their IDs: if (!Guid.TryParseExact(Path.GetFileNameWithoutExtension(path), "D", out _)) continue; try { if (await this.LoadPersonResearchWithoutInitializeAsync(path, token) is { } found) research.Add(found); } catch (InvalidDataException exception) { logger.LogWarning(exception, "Could not open the research on a person of the preparation '{ProjectId}' of the BeyondFacts Lab.", projectId); } } return research; } /// /// Stores the research on one person of a preparation, in place of the earlier one. /// /// The ID of the preparation. /// The research. /// Cancels the storing. /// The preparation, with the time of its last change. /// The preparation does not exist. /// The earlier research is damaged, or a newer version of AI Studio stored it; it stays as it is. public async Task SavePersonResearchAsync(Guid projectId, BflPersonResearch research, CancellationToken token = default) { await this.InitializeAsync(token); var gate = this.GetLock(projectId); await gate.WaitAsync(token); try { var project = await this.LoadRequiredWithoutInitializeAsync(projectId, token); var path = this.PersonResearchPath(projectId, BflPersonId.Of(research.Name)); // A research which a newer version stored, or which is damaged, stays as it is: await this.LoadPersonResearchWithoutInitializeAsync(path, token); research.SchemaVersion = BflPersonResearch.CURRENT_SCHEMA_VERSION; foreach (var stage in research.Stages) stage.Research.SchemaVersion = BflResearch.CURRENT_SCHEMA_VERSION; await WriteTextAtomicAsync(path, JsonSerializer.Serialize(research, JSON_OPTIONS), token); project.ModifiedAtUtc = DateTimeOffset.UtcNow; await this.StoreProjectAsync(project, token); return project; } finally { gate.Release(); } } /// /// Loads the research on one person from its file. /// /// The file. /// Cancels the loading. /// The research, or null when the file does not exist. /// The research is damaged, belongs to another file, or a newer version of AI Studio stored it. private async Task LoadPersonResearchWithoutInitializeAsync(string path, CancellationToken token) { if (!File.Exists(path)) return null; BflPersonResearch? research; try { await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, true); research = await JsonSerializer.DeserializeAsync(stream, JSON_OPTIONS, token); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) { throw new InvalidDataException("This version of AI Studio cannot open the research on a person.", exception); } if (research is null || research.SchemaVersion is < 1 or > BflPersonResearch.CURRENT_SCHEMA_VERSION || IsNull(research.Name) || IsNull(research.Stages) || research.Stages.Any(stage => IsNull(stage) || IsNull(stage.Research) || IsBroken(stage.Research))) throw new InvalidDataException("This version of AI Studio cannot open the research on a person."); // The file of a person is named by the ID of their name; a research under another name is no research of theirs: if (!string.Equals(Path.GetFileNameWithoutExtension(path), BflPersonId.Of(research.Name).ToString("D"), StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("The research on a person is stored under the name of another person."); return research; } /// /// Loads whom of the list of participants the lab researches in depth. /// /// The ID of the preparation. /// Cancels the loading. /// The selection, or null when the file does not exist. /// The selection is damaged, or a newer version of AI Studio stored it. private async Task LoadParticipantSelectionWithoutInitializeAsync(Guid projectId, CancellationToken token) { var path = this.ParticipantSelectionPath(projectId); if (!File.Exists(path)) return null; BflParticipantSelection? selection; try { await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 65_536, true); selection = await JsonSerializer.DeserializeAsync(stream, JSON_OPTIONS, token); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or JsonException) { throw new InvalidDataException("This version of AI Studio cannot open the selection of participants.", exception); } if (selection is null || selection.SchemaVersion is < 1 or > BflParticipantSelection.CURRENT_SCHEMA_VERSION || IsNull(selection.Names) || selection.Names.Any(IsNull)) throw new InvalidDataException("This version of AI Studio cannot open the selection of participants."); return selection; } /// /// Gets the file with the selection of participants of a preparation. /// /// The ID of the preparation. private string ParticipantSelectionPath(Guid projectId) => Path.Combine(this.ProjectDirectory(projectId), STEPS_DIRECTORY_NAME, BflStep.STRATEGY.FolderName, RESEARCH_DIRECTORY_NAME, PARTICIPANT_SELECTION_FILE_NAME); /// /// Gets the folder with the research on the people of a preparation. /// /// The ID of the preparation. private string PersonResearchDirectory(Guid projectId) => Path.Combine(this.ProjectDirectory(projectId), STEPS_DIRECTORY_NAME, BflStep.STRATEGY.FolderName, RESEARCH_DIRECTORY_NAME, PERSON_RESEARCH_DIRECTORY_NAME); /// /// Gets the file with the research on one person. /// /// The ID of the preparation. /// The ID of the person, see BflPersonId. private string PersonResearchPath(Guid projectId, Guid personId) => Path.Combine(this.PersonResearchDirectory(projectId), $"{personId:D}.json"); }