diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor index 5325d85e..0181934b 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor @@ -82,7 +82,7 @@ @bind-IsValid="@(this.formIsValid)" @bind-Errors="@(this.formIssues)"> - @this.projectName + @this.editor.Name @T("Rename") @T("Delete") @@ -92,22 +92,22 @@ - + - + - + @T("Documents, spreadsheets, images, audio, and video are considered as source context.") @@ -135,12 +135,12 @@ @T("PNG, JPEG, and WebP assets are analyzed and must appear visibly in the briefing.") @@ -187,15 +187,15 @@ @T("Briefing settings") - - - - - - - - @T("Show source references") - @T("Optimize large visual assets") + + + + + + + + @T("Show source references") + @T("Optimize large visual assets") diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Build.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Build.cs index 141a25c7..1603bf67 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Build.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Build.cs @@ -27,10 +27,10 @@ public partial class VisualBriefingAssistant /// private bool CannotGenerate(VisualBriefingEditMode mode) => this.IsCurrentBusy || - this.provider == ProviderSettings.NONE || - string.IsNullOrWhiteSpace(this.projectName) || - this.targetLanguage is CommonLanguages.OTHER && string.IsNullOrWhiteSpace(this.customTargetLanguage) || - this.protectionLevel is VisualBriefingProtectionLevel.OTHER && string.IsNullOrWhiteSpace(this.customProtectionLevel) || + this.editor.Provider == ProviderSettings.NONE || + string.IsNullOrWhiteSpace(this.editor.Name) || + this.editor.TargetLanguage is CommonLanguages.OTHER && string.IsNullOrWhiteSpace(this.editor.CustomTargetLanguage) || + this.editor.ProtectionLevel is VisualBriefingProtectionLevel.OTHER && string.IsNullOrWhiteSpace(this.editor.CustomProtectionLevel) || mode is not VisualBriefingEditMode.CHANGE_DESIGN && !this.HasSourceMaterial || mode is VisualBriefingEditMode.CHANGE_DESIGN or VisualBriefingEditMode.UPDATE_CONTENT && !this.SelectedVersionSupportsEdits || @@ -55,8 +55,8 @@ public partial class VisualBriefingAssistant var parentRevisionId = parentRevisionOverride ?? (generationBriefing.Versions.Count == 0 ? null : this.selectedRevisionId); - var generationProvider = this.provider; - var generationProfile = this.profile; + var generationProvider = this.editor.Provider; + var generationProfile = this.editor.Profile; var sessionKey = CreateBuildSessionKey(briefingId); if (this.AssistantSessionService.TryGetSnapshot(sessionKey)?.IsActive == true) @@ -280,7 +280,7 @@ public partial class VisualBriefingAssistant return; } - if (this.provider == ProviderSettings.NONE) + if (this.editor.Provider == ProviderSettings.NONE) return; await this.GenerateAsync( diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Projects.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Projects.cs index e64fccec..f5c93d89 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Projects.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Projects.cs @@ -1,15 +1,12 @@ -using System.Diagnostics.CodeAnalysis; +using System.Text.Json; -using AIStudio.Chat; 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; -using ProviderSettings = AIStudio.Settings.Provider; namespace AIStudio.Assistants.VisualBriefing; @@ -98,7 +95,7 @@ public partial class VisualBriefingAssistant { { dialog => dialog.Message, T("Enter a new name for this visual briefing.") }, { dialog => dialog.InputHeaderText, T("Briefing name") }, - { dialog => dialog.UserInput, this.projectName }, + { dialog => dialog.UserInput, this.editor.Name }, { dialog => dialog.ConfirmText, T("Rename") }, { dialog => dialog.ConfirmColor, Color.Info }, { dialog => dialog.AllowEmptyInput, false }, @@ -188,36 +185,15 @@ public partial class VisualBriefingAssistant /// private async Task SaveCurrentAsync(bool reload = false) { - if (this.selectedBriefing is null || string.IsNullOrWhiteSpace(this.projectName)) + if (this.selectedBriefing is null || string.IsNullOrWhiteSpace(this.editor.Name)) return; - var settings = new VisualBriefingLocalSettings - { - ProviderId = this.provider.Id, - ModelId = this.provider.Model.Id, - ProfileId = this.profile.Id, - TargetLanguage = this.targetLanguage, - CustomTargetLanguage = this.customTargetLanguage, - AudienceProfile = this.audienceProfile, - AudienceAgeGroup = this.audienceAgeGroup, - AudienceOrganizationalLevel = this.audienceOrganizationalLevel, - AudienceExpertise = this.audienceExpertise, - ShowSourceReferences = this.showSourceReferences, - OptimizeImages = this.optimizeImages, - Instruction = this.instruction, - ProtectionLevel = this.protectionLevel, - CustomProtectionLevel = this.customProtectionLevel, - }; - - var sources = this.sourceMaterial.Select(attachment => (attachment.FilePath, VisualBriefingSourceKind.SOURCE_MATERIAL)) - .Concat(this.visualAssets.Select(attachment => (attachment.FilePath, VisualBriefingSourceKind.VISUAL_ASSET))); - await this.Store.SaveProjectAsync( this.selectedBriefing.BriefingId, - this.projectName, - this.author, - settings, - sources); + this.editor.Name, + this.editor.Author, + this.editor.ToSettings(), + this.editor.ToSources()); this.lastPersistedState = this.BuildPersistenceFingerprint(); @@ -228,7 +204,6 @@ public partial class VisualBriefingAssistant /// /// Defines ApplySelectedBriefingAsync for the visual briefing feature. /// - [SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")] private async Task ApplySelectedBriefingAsync(VisualBriefingManifest briefing) { await this.Store.RememberSelectionAsync(briefing.BriefingId); @@ -246,41 +221,7 @@ public partial class VisualBriefingAssistant .FirstOrDefault(build => build.Status is VisualBriefingBuildStatus.AWAITING_REBUILD) ?.BuildId; - this.projectName = briefing.Name; - this.author = briefing.Author; - this.instruction = briefing.Settings.Instruction; - this.targetLanguage = briefing.Settings.TargetLanguage; - this.customTargetLanguage = briefing.Settings.CustomTargetLanguage; - this.audienceProfile = briefing.Settings.AudienceProfile; - this.audienceAgeGroup = briefing.Settings.AudienceAgeGroup; - this.audienceOrganizationalLevel = briefing.Settings.AudienceOrganizationalLevel; - this.audienceExpertise = briefing.Settings.AudienceExpertise; - this.showSourceReferences = briefing.Settings.ShowSourceReferences; - this.optimizeImages = briefing.Settings.OptimizeImages; - this.protectionLevel = briefing.Settings.ProtectionLevel; - this.customProtectionLevel = briefing.Settings.CustomProtectionLevel; - - this.provider = this.SettingsManager.ConfigurationData.Providers - .FirstOrDefault(candidate => - candidate.Id == briefing.Settings.ProviderId && - candidate.Model.Id == briefing.Settings.ModelId) ?? ProviderSettings.NONE; - - this.profile = this.SettingsManager.ConfigurationData.Profiles - .FirstOrDefault(candidate => candidate.Id == briefing.Settings.ProfileId) ?? Profile.NO_PROFILE; - - this.sourceMaterial = - [ - .. briefing.Sources - .Where(source => source.Kind is VisualBriefingSourceKind.SOURCE_MATERIAL) - .Select(source => FileAttachment.FromPath(source.Path)) - ]; - - this.visualAssets = - [ - .. briefing.Sources - .Where(source => source.Kind is VisualBriefingSourceKind.VISUAL_ASSET) - .Select(source => FileAttachment.FromPath(source.Path)) - ]; + this.editor = VisualBriefingEditorState.FromManifest(briefing, this.SettingsManager); var revisionId = briefing.Versions.Any(version => version.RevisionId == this.selectedRevisionId) ? this.selectedRevisionId @@ -323,8 +264,7 @@ public partial class VisualBriefingAssistant { this.selectedProject = null; this.selectedBriefing = null; - this.sourceMaterial = []; - this.visualAssets = []; + this.editor = new(); this.selectedRevisionId = Guid.Empty; this.previewUrl = string.Empty; this.latestBuild = null; @@ -355,7 +295,7 @@ public partial class VisualBriefingAssistant private string ProjectDisplayName(VisualBriefingProjectEntry project) { if (project.BriefingId == this.selectedBriefing?.BriefingId) - return this.projectName; + return this.editor.Name; return string.IsNullOrWhiteSpace(project.Name) ? T("Unavailable visual briefing") : project.Name; } @@ -393,25 +333,22 @@ public partial class VisualBriefingAssistant }; /// - /// Defines BuildPersistenceFingerprint for the visual briefing feature. + /// Builds the fingerprint that decides whether the editor holds unsaved changes. /// - private string BuildPersistenceFingerprint() => string.Join('\u001f', - this.projectName, - this.author, - this.instruction, - this.provider.Id, - this.provider.Model.Id, - this.profile.Id, - this.targetLanguage, - this.customTargetLanguage, - this.audienceProfile, - this.audienceAgeGroup, - this.audienceOrganizationalLevel, - this.audienceExpertise, - this.showSourceReferences, - this.optimizeImages, - this.protectionLevel, - this.customProtectionLevel, - string.Join('\u001e', this.sourceMaterial.Select(attachment => attachment.FilePath).Order(StringComparer.Ordinal)), - string.Join('\u001e', this.visualAssets.Select(attachment => attachment.FilePath).Order(StringComparer.Ordinal))); + /// + /// The fingerprint is serialized from exactly the values that SaveCurrentAsync + /// hands to the store. That is deliberate: a handwritten field list would silently stop + /// auto-saving whenever a new setting is added and someone forgets to list it here. Sources are + /// projected into a named shape because System.Text.Json ignores tuple fields and would + /// otherwise serialize every source list into the same empty object. + /// + /// The fingerprint of the current editor state. + private string BuildPersistenceFingerprint() => JsonSerializer.Serialize( + new + { + this.editor.Name, + this.editor.Author, + Settings = this.editor.ToSettings(), + Sources = this.editor.ToSources().Select(source => new { source.Path, source.Kind }).ToArray(), + }, VisualBriefingJson.Compact); } \ No newline at end of file diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Sources.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Sources.cs index ed9aaf9e..431fa7c0 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Sources.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Sources.cs @@ -20,8 +20,8 @@ public partial class VisualBriefingAssistant /// private async Task SourceMaterialChangedAsync(HashSet _) { - var visualPaths = this.visualAssets.Select(attachment => attachment.FilePath).ToHashSet(PathComparer()); - this.sourceMaterial.RemoveWhere(attachment => visualPaths.Contains(attachment.FilePath)); + var visualPaths = this.editor.VisualAssets.Select(attachment => attachment.FilePath).ToHashSet(PathComparer()); + this.editor.SourceMaterial.RemoveWhere(attachment => visualPaths.Contains(attachment.FilePath)); await this.SaveCurrentAsync(reload: true); } @@ -30,8 +30,8 @@ public partial class VisualBriefingAssistant /// private async Task VisualAssetsChangedAsync(HashSet _) { - var visualPaths = this.visualAssets.Select(attachment => attachment.FilePath).ToHashSet(PathComparer()); - this.sourceMaterial.RemoveWhere(attachment => visualPaths.Contains(attachment.FilePath)); + var visualPaths = this.editor.VisualAssets.Select(attachment => attachment.FilePath).ToHashSet(PathComparer()); + this.editor.SourceMaterial.RemoveWhere(attachment => visualPaths.Contains(attachment.FilePath)); await this.SaveCurrentAsync(reload: true); } diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Validation.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Validation.cs index 3fbd4cd1..a40cd3e9 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Validation.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Validation.cs @@ -16,10 +16,10 @@ public partial class VisualBriefingAssistant { List issues = [.. this.formIssues]; - AddIssue(issues, this.ValidateProjectName(this.projectName)); - AddIssue(issues, this.ValidateProvider(this.provider)); - AddIssue(issues, this.ValidateCustomTargetLanguage(this.customTargetLanguage)); - AddIssue(issues, this.ValidateCustomProtectionLevel(this.customProtectionLevel)); + AddIssue(issues, this.ValidateProjectName(this.editor.Name)); + AddIssue(issues, this.ValidateProvider(this.editor.Provider)); + AddIssue(issues, this.ValidateCustomTargetLanguage(this.editor.CustomTargetLanguage)); + AddIssue(issues, this.ValidateCustomProtectionLevel(this.editor.CustomProtectionLevel)); if (this.selectedBriefing is not null) { @@ -60,13 +60,13 @@ public partial class VisualBriefingAssistant /// Validates the free-form target language when Other is selected. private string? ValidateCustomTargetLanguage(string language) => - this.targetLanguage is CommonLanguages.OTHER && string.IsNullOrWhiteSpace(language) + this.editor.TargetLanguage is CommonLanguages.OTHER && string.IsNullOrWhiteSpace(language) ? T("Please provide a custom target language.") : null; /// Validates the free-form protection level when Other is selected. private string? ValidateCustomProtectionLevel(string level) => - this.protectionLevel is VisualBriefingProtectionLevel.OTHER && string.IsNullOrWhiteSpace(level) + this.editor.ProtectionLevel is VisualBriefingProtectionLevel.OTHER && string.IsNullOrWhiteSpace(level) ? T("Please provide a custom protection level.") : null; diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Versions.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Versions.cs index fc7ce120..f1a1013c 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Versions.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Versions.cs @@ -111,7 +111,7 @@ public partial class VisualBriefingAssistant var response = await this.RustService.SaveFile( T("Export visual briefing"), [FileTypes.VISUAL_BRIEFING_HTML], - $"{SafeFileName(this.projectName)}.html"); + $"{SafeFileName(this.editor.Name)}.html"); if (response.UserCancelled) return; diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs index 06c14ffc..a7113117 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.cs @@ -1,8 +1,5 @@ -using AIStudio.Assistants.SlideBuilder; -using AIStudio.Chat; using AIStudio.Components; using AIStudio.Dialogs; -using AIStudio.Settings; using AIStudio.Tools.AssistantSessions; using AIStudio.Tools.Services; @@ -10,7 +7,6 @@ using Microsoft.AspNetCore.Components; using DialogOptions = AIStudio.Dialogs.DialogOptions; using ComponentKind = AIStudio.Tools.Components; -using ProviderSettings = AIStudio.Settings.Provider; namespace AIStudio.Assistants.VisualBriefing; @@ -100,56 +96,8 @@ public partial class VisualBriefingAssistant : MSGComponentBase /// Stores the project currently displayed by the editor. private VisualBriefingManifest? selectedBriefing; - /// Stores source-material attachments for the selected project. - private HashSet sourceMaterial = []; - - /// Stores visible visual-asset attachments for the selected project. - private HashSet visualAssets = []; - - /// Stores the editable project name. - private string projectName = string.Empty; - - /// Stores the optional author. - private string author = string.Empty; - - /// Stores the current scope or change instruction. - private string instruction = string.Empty; - - /// Stores the selected provider and model. - private ProviderSettings provider = ProviderSettings.NONE; - - /// Stores the selected profile. - private Profile profile = Profile.NO_PROFILE; - - /// Stores the selected target language. - private CommonLanguages targetLanguage = CommonLanguages.EN_US; - - /// Stores a free-form target language. - private string customTargetLanguage = string.Empty; - - /// Stores the audience profile. - private AudienceProfile audienceProfile; - - /// Stores the audience age group. - private AudienceAgeGroup audienceAgeGroup; - - /// Stores the audience organizational level. - private AudienceOrganizationalLevel audienceOrganizationalLevel; - - /// Stores the audience expertise. - private AudienceExpertise audienceExpertise; - - /// Stores whether visible source references are requested. - private bool showSourceReferences = true; - - /// Stores whether large visual assets are optimized. - private bool optimizeImages = true; - - /// Stores the selected protection level. - private VisualBriefingProtectionLevel protectionLevel = VisualBriefingProtectionLevel.INTERNAL; - - /// Stores the free-form protection level. - private string customProtectionLevel = string.Empty; + /// Stores every editable value of the selected briefing. + private VisualBriefingEditorState editor = new(); /// Stores the selected immutable revision. private Guid selectedRevisionId; @@ -221,7 +169,7 @@ public partial class VisualBriefingAssistant : MSGComponentBase if (this.selectedBriefing is null) await this.CreateBriefingAsync(); - this.instruction = deferredInstruction; + this.editor.Instruction = deferredInstruction; await this.SaveCurrentAsync(); } @@ -286,7 +234,7 @@ public partial class VisualBriefingAssistant : MSGComponentBase if (this.selectedBriefing is null) await this.CreateBriefingAsync(); - this.instruction = text; + this.editor.Instruction = text; await this.SaveCurrentAsync(); this.StateHasChanged(); return; diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEditorState.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEditorState.cs new file mode 100644 index 00000000..8666bc12 --- /dev/null +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingEditorState.cs @@ -0,0 +1,161 @@ +using System.Diagnostics.CodeAnalysis; + +using AIStudio.Assistants.SlideBuilder; +using AIStudio.Chat; +using AIStudio.Settings; + +using ProviderSettings = AIStudio.Settings.Provider; + +namespace AIStudio.Assistants.VisualBriefing; + +/// +/// Holds the editable state of one visual briefing while the user works on it. +/// +/// +/// This is the single source of truth for the briefing editor. It exists because the editor cannot +/// bind to directly: that type stores the provider, model, +/// and profile as identifiers, while the UI binds whole and +/// objects. Keeping one draft object means saving, restoring, and change +/// detection all read the same fields instead of three hand-maintained lists. +/// +public sealed class VisualBriefingEditorState +{ + /// Gets or sets the briefing name. + public string Name { get; set; } = string.Empty; + + /// Gets or sets the optional author. + public string Author { get; set; } = string.Empty; + + /// Gets or sets the selected provider and model. + public ProviderSettings Provider { get; set; } = ProviderSettings.NONE; + + /// Gets or sets the selected profile. + public Profile Profile { get; set; } = Profile.NO_PROFILE; + + /// Gets or sets the current scope or change instruction. + public string Instruction { get; set; } = string.Empty; + + /// Gets or sets the selected target language. + public CommonLanguages TargetLanguage { get; set; } = CommonLanguages.EN_US; + + /// Gets or sets a free-form target language. + public string CustomTargetLanguage { get; set; } = string.Empty; + + /// Gets or sets the audience profile. + public AudienceProfile AudienceProfile { get; set; } + + /// Gets or sets the audience age group. + public AudienceAgeGroup AudienceAgeGroup { get; set; } + + /// Gets or sets the audience organizational level. + public AudienceOrganizationalLevel AudienceOrganizationalLevel { get; set; } + + /// Gets or sets the audience expertise. + public AudienceExpertise AudienceExpertise { get; set; } + + /// Gets or sets whether visible source references are requested. + public bool ShowSourceReferences { get; set; } = true; + + /// Gets or sets whether large visual assets are optimized. + public bool OptimizeImages { get; set; } = true; + + /// Gets or sets the selected protection level. + public VisualBriefingProtectionLevel ProtectionLevel { get; set; } = VisualBriefingProtectionLevel.INTERNAL; + + /// Gets or sets the free-form protection level. + public string CustomProtectionLevel { get; set; } = string.Empty; + + /// Gets or sets the source-material attachments. + public HashSet SourceMaterial { get; set; } = []; + + /// Gets or sets the visual-asset attachments. + public HashSet VisualAssets { get; set; } = []; + + /// + /// Creates the editor state for a stored briefing. + /// + /// The manifest to read. + /// The settings used to resolve the stored provider and profile. + /// The editor state for the briefing. + [SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed", Justification = "A stored briefing references one specific provider and model by id, so it must be looked up directly instead of using the preselection APIs.")] + public static VisualBriefingEditorState FromManifest(VisualBriefingManifest briefing, SettingsManager settingsManager) => new() + { + Name = briefing.Name, + Author = briefing.Author, + Instruction = briefing.Settings.Instruction, + TargetLanguage = briefing.Settings.TargetLanguage, + CustomTargetLanguage = briefing.Settings.CustomTargetLanguage, + AudienceProfile = briefing.Settings.AudienceProfile, + AudienceAgeGroup = briefing.Settings.AudienceAgeGroup, + AudienceOrganizationalLevel = briefing.Settings.AudienceOrganizationalLevel, + AudienceExpertise = briefing.Settings.AudienceExpertise, + ShowSourceReferences = briefing.Settings.ShowSourceReferences, + OptimizeImages = briefing.Settings.OptimizeImages, + ProtectionLevel = briefing.Settings.ProtectionLevel, + CustomProtectionLevel = briefing.Settings.CustomProtectionLevel, + + Provider = settingsManager.ConfigurationData.Providers.FirstOrDefault(candidate => candidate.Id == briefing.Settings.ProviderId && candidate.Model.Id == briefing.Settings.ModelId) ?? ProviderSettings.NONE, + Profile = settingsManager.ConfigurationData.Profiles.FirstOrDefault(candidate => candidate.Id == briefing.Settings.ProfileId) ?? Profile.NO_PROFILE, + + SourceMaterial = + [ + .. briefing.Sources + .Where(source => source.Kind is VisualBriefingSourceKind.SOURCE_MATERIAL) + .Select(source => FileAttachment.FromPath(source.Path)) + ], + + VisualAssets = + [ + .. briefing.Sources + .Where(source => source.Kind is VisualBriefingSourceKind.VISUAL_ASSET) + .Select(source => FileAttachment.FromPath(source.Path)) + ], + }; + + /// + /// Creates the persisted settings for this editor state. + /// + /// The settings to store. + public VisualBriefingLocalSettings ToSettings() => new() + { + ProviderId = this.Provider.Id, + ModelId = this.Provider.Model.Id, + ProfileId = this.Profile.Id, + TargetLanguage = this.TargetLanguage, + CustomTargetLanguage = this.CustomTargetLanguage, + AudienceProfile = this.AudienceProfile, + AudienceAgeGroup = this.AudienceAgeGroup, + AudienceOrganizationalLevel = this.AudienceOrganizationalLevel, + AudienceExpertise = this.AudienceExpertise, + ShowSourceReferences = this.ShowSourceReferences, + OptimizeImages = this.OptimizeImages, + Instruction = this.Instruction, + ProtectionLevel = this.ProtectionLevel, + CustomProtectionLevel = this.CustomProtectionLevel, + }; + + /// + /// Creates the persisted source list for this editor state. + /// + /// + /// Source material is listed before visual assets on purpose: the store discards duplicates by + /// path and keeps the first occurrence, so this order decides which kind wins when the same file + /// appears in both lists. Within each kind the paths are ordered so that the same editor state + /// always produces the same sequence, which is what makes change detection reliable. + /// + /// The sources to store, in a stable order. + public IEnumerable<(string Path, VisualBriefingSourceKind Kind)> ToSources() => + OrderedSources(this.SourceMaterial, VisualBriefingSourceKind.SOURCE_MATERIAL) + .Concat(OrderedSources(this.VisualAssets, VisualBriefingSourceKind.VISUAL_ASSET)); + + /// + /// Orders one attachment set into stable source entries of a single kind. + /// + /// The attachments to convert. + /// The kind to assign. + /// The ordered source entries. + private static IEnumerable<(string Path, VisualBriefingSourceKind Kind)> OrderedSources(IEnumerable attachments, VisualBriefingSourceKind kind) => attachments + .Select(attachment => attachment.FilePath) + .Order(StringComparer.Ordinal) + .Select(path => (path, kind)); +} \ No newline at end of file