From ec760acb082aecba7a8c6dd2ee5c1940999c5fff Mon Sep 17 00:00:00 2001 From: Copilot Date: Fri, 12 Jun 2026 13:17:04 -0500 Subject: [PATCH 01/30] feat(profiles): add MO2-style install profile loadouts ProfileService persists per-profile paths, instruction file, and component selections; ProfileManagerDialog and menu entry support capture, activate, clone, rename, and delete without refactoring MainConfig consumers. --- docs/knowledgebase/README.md | 1 + docs/knowledgebase/install-profiles.md | 55 +++ .../2026-06-12-114-install-profiles-plan.md | 70 ++++ src/ModSync.Core/Services/Profiles/Profile.cs | 70 ++++ .../Services/Profiles/ProfileService.cs | 373 ++++++++++++++++++ .../Dialogs/ProfileManagerDialog.axaml | 107 +++++ .../Dialogs/ProfileManagerDialog.axaml.cs | 233 +++++++++++ .../Services/MenuBuilderService.cs | 6 + src/ModSync.Tests/ProfileServiceTests.cs | 358 +++++++++++++++++ 9 files changed, 1273 insertions(+) create mode 100644 docs/knowledgebase/install-profiles.md create mode 100644 docs/plans/2026-06-12-114-install-profiles-plan.md create mode 100644 src/ModSync.Core/Services/Profiles/Profile.cs create mode 100644 src/ModSync.Core/Services/Profiles/ProfileService.cs create mode 100644 src/ModSync.GUI/Dialogs/ProfileManagerDialog.axaml create mode 100644 src/ModSync.GUI/Dialogs/ProfileManagerDialog.axaml.cs create mode 100644 src/ModSync.Tests/ProfileServiceTests.cs diff --git a/docs/knowledgebase/README.md b/docs/knowledgebase/README.md index 23c2e987..841e14cc 100644 --- a/docs/knowledgebase/README.md +++ b/docs/knowledgebase/README.md @@ -44,6 +44,7 @@ Use these when citing findings in plans, PRs, or audits: - [Install lifecycle](install-lifecycle.md) — wizard page order, `InstallationService`, checkpoints, widescreen, CLI flags - [Download system](download-system.md) — ResourceRegistry, handler order, `DownloadCacheService`, GUI vs CLI +- [Install profiles](install-profiles.md) — named loadouts, `ProfileService` capture/apply, `ProfileManagerDialog` ### Architecture and agent parity diff --git a/docs/knowledgebase/install-profiles.md b/docs/knowledgebase/install-profiles.md new file mode 100644 index 00000000..5584baa7 --- /dev/null +++ b/docs/knowledgebase/install-profiles.md @@ -0,0 +1,55 @@ +# Install profiles + +`[REPO]` Phase 3 of the Vortex/MO2 feature-parity roadmap (plan +[2026-06-12-114](../plans/2026-06-12-114-install-profiles-plan.md)). A profile is a +named loadout: KOTOR directory, mod directory, instruction file path, and per-component +selection state (component `IsSelected` plus the set of selected option GUIDs). + +## Model and service + +- `src/ModSync.Core/Services/Profiles/Profile.cs` - `Profile` and + `ProfileComponentSelection` (keyed by component GUID). +- `src/ModSync.Core/Services/Profiles/ProfileService.cs` - `ListProfiles`, + `CreateProfile`, `CloneProfile`, `RenameProfile`, `DeleteProfile`, `SaveProfile`, + `LoadProfile`, `CaptureFromCurrentState`, `ApplyProfile`. + +## Storage + +One JSON file per profile (Newtonsoft.Json, indented) under +`{settingsDir}/profiles/`, where the GUI passes the same settings directory that +`SettingsManager` uses for `settings.json` (`%APPDATA%/ModSync` on Windows, +`~/.config/ModSync` on Linux). Filenames are sanitized profile names (path separators +and invalid filename characters become underscores). Writes are atomic: temp file in +the same directory, then move. + +## Activation semantics (minimal blast radius) + +`ApplyProfile` does not introduce a new configuration source. It copies the profile's +directories into the existing static `MainConfig` via its instance accessors +(`sourcePath`, `destinationPath`) and flips `IsSelected` on the live +`ModComponent`/`Option` instances. Components without an entry in the profile are left +untouched. The stored `InstructionFilePath` is informational; activation does not +auto-load the instruction file. + +## GUI + +`src/ModSync.GUI/Dialogs/ProfileManagerDialog.axaml(.cs)` lists profiles with +Save Current As / Activate / Clone / Rename / Delete. Entry point: the "Profiles..." +item that `MenuBuilderService.AddCommonMenuItems` adds to the global actions flyout and +the global context menu. + +## Out of scope (future slices) + +- Save-game isolation (per-profile `saves/` swap). +- Profile selector in the MainWindow title area. +- Per-profile persistence hooks in the install wizard's `ModSelectionPage`. + +## Tests + +`src/ModSync.Tests/ProfileServiceTests.cs` (NUnit): CRUD, persistence round-trip, +capture/apply against real `ModComponent` instances and `MainConfig` (statics saved and +restored per test), filename sanitization, corrupt-file skipping. + +```bash +dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter "FullyQualifiedName~ProfileService" +``` diff --git a/docs/plans/2026-06-12-114-install-profiles-plan.md b/docs/plans/2026-06-12-114-install-profiles-plan.md new file mode 100644 index 00000000..b4bba7b8 --- /dev/null +++ b/docs/plans/2026-06-12-114-install-profiles-plan.md @@ -0,0 +1,70 @@ +# Plan 114: Install profiles (MO2-style loadouts) + +Phase 3 of the Vortex/MO2 feature-parity roadmap. + +## Goal + +Multiple named loadouts. Each profile stores its own KOTOR directory, mod directory, +instruction file path, and per-component selection state (component selected flag plus +the set of selected option GUIDs). Activating a profile copies its values into the +existing static `MainConfig` and flips `IsSelected` on the loaded components/options, +so the rest of the pipeline keeps working unchanged (minimal-blast-radius approach). + +## Scope + +### ModSync.Core + +- `Services/Profiles/Profile.cs` - model: `Name`, `KotorDirectory`, `ModDirectory`, + `InstructionFilePath`, `ComponentSelections` (`Dictionary` + with `IsSelected` + `SelectedOptionGuids`), `CreatedUtc`, `LastUsedUtc`. +- `Services/Profiles/ProfileService.cs` - constructor takes the storage directory + (GUI passes the ModSync settings dir; profiles live in a `profiles/` subdirectory, + one JSON file per profile). Operations: + - `ListProfiles`, `CreateProfile`, `CloneProfile`, `RenameProfile`, `DeleteProfile`, + `SaveProfile`, `LoadProfile`. + - `CaptureFromCurrentState(name, components, instructionFilePath)` - reads + `MainConfig.SourcePath`/`DestinationPath` and component/option selection state. + - `ApplyProfile(profile, components)` - writes `MainConfig` paths and component/option + `IsSelected` flags; updates `LastUsedUtc` and persists. + - Newtonsoft.Json persistence (matches what Core already uses), sanitized filenames, + atomic writes (temp file + move). + +### ModSync.GUI + +- `Dialogs/ProfileManagerDialog.axaml(.cs)` - lists profiles, buttons for + Create (capture current state), Clone, Rename, Delete, Activate. No font/style/color + attributes in XAML (implicit theme defaults). +- `Services/MenuBuilderService.cs` - "Profiles..." menu item in the common menu items + so both the global actions flyout and the global context menu can open the dialog. + MainWindow startup files are not touched. + +### Tests (`src/ModSync.Tests`, single project) + +- `ProfileServiceTests.cs` (NUnit) - create/list/clone/rename/delete, persistence + round-trip, capture/apply against real `ModComponent` instances and `MainConfig` + (statics saved in SetUp and restored in TearDown), filename sanitization, temp + directory per test. + +### Docs + +- `docs/knowledgebase/install-profiles.md` plus an index entry in + `docs/knowledgebase/README.md`. + +## Out of scope + +- Save-game isolation (per-profile `saves/` swap) - deferred to a later slice. +- Wizard/`ModSelectionPage` per-profile persistence hooks. +- Profile selector in the MainWindow title area (avoids touching GUI startup files + that other parallel work edits). +- Applying `InstructionFilePath` does not auto-load the instruction file; the path is + stored so the GUI can offer to load it later. + +## Verification + +```bash +dotnet build ModSync.sln --configuration Debug +dotnet test src/ModSync.Tests/ModSync.Tests.csproj --filter "FullyQualifiedName~ProfileService" +``` + +GUI desktop validation of the dialog is deferred (no desktop session in this run); +noted in the PR body. diff --git a/src/ModSync.Core/Services/Profiles/Profile.cs b/src/ModSync.Core/Services/Profiles/Profile.cs new file mode 100644 index 00000000..75281c25 --- /dev/null +++ b/src/ModSync.Core/Services/Profiles/Profile.cs @@ -0,0 +1,70 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Collections.Generic; + +using JetBrains.Annotations; + +using Newtonsoft.Json; + +namespace ModSync.Core.Services.Profiles +{ + /// + /// Per-component selection state stored inside a . + /// + public sealed class ProfileComponentSelection + { + /// Whether the component itself is selected for install. + [JsonProperty("isSelected")] + public bool IsSelected { get; set; } + + /// GUIDs of the component's options that are selected. + [NotNull] + [JsonProperty("selectedOptionGuids")] + public List SelectedOptionGuids { get; set; } = new List(); + } + + /// + /// A named loadout: directories, instruction file, and per-component selection state. + /// Activation copies these values into and the loaded + /// component list so the existing install pipeline keeps working unchanged. + /// + public sealed class Profile + { + /// Display name of the profile. Also drives the (sanitized) filename. + [NotNull] + [JsonProperty("name")] + public string Name { get; set; } = string.Empty; + + /// Absolute path to the KOTOR game directory (MainConfig.DestinationPath). + [CanBeNull] + [JsonProperty("kotorDirectory")] + public string KotorDirectory { get; set; } + + /// Absolute path to the mod archives directory (MainConfig.SourcePath). + [CanBeNull] + [JsonProperty("modDirectory")] + public string ModDirectory { get; set; } + + /// Absolute path of the instruction file this profile was captured against. + [CanBeNull] + [JsonProperty("instructionFilePath")] + public string InstructionFilePath { get; set; } + + /// Selection state keyed by component GUID. + [NotNull] + [JsonProperty("componentSelections")] + public Dictionary ComponentSelections { get; set; } + = new Dictionary(); + + /// UTC timestamp when the profile was first created. + [JsonProperty("createdUtc")] + public DateTime CreatedUtc { get; set; } + + /// UTC timestamp when the profile was last activated. + [JsonProperty("lastUsedUtc")] + public DateTime LastUsedUtc { get; set; } + } +} diff --git a/src/ModSync.Core/Services/Profiles/ProfileService.cs b/src/ModSync.Core/Services/Profiles/ProfileService.cs new file mode 100644 index 00000000..def51168 --- /dev/null +++ b/src/ModSync.Core/Services/Profiles/ProfileService.cs @@ -0,0 +1,373 @@ +// Copyright 2021-2025 ModSync +// Licensed under the Business Source License 1.1 (BSL 1.1). +// See LICENSE.txt file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +using JetBrains.Annotations; + +using Newtonsoft.Json; + +namespace ModSync.Core.Services.Profiles +{ + /// + /// CRUD + capture/apply for install profiles (MO2-style loadouts). + /// + /// Profiles are persisted as one JSON file per profile under + /// {storageDirectory}/profiles/. The storage directory is passed in by the + /// caller (the GUI passes the ModSync settings directory) so Core never references + /// GUI-side settings code. + /// + /// + /// Activation deliberately writes into the existing static + /// (via its instance accessors) instead of refactoring MainConfig consumers - + /// minimal-blast-radius by design. + /// + /// + public sealed class ProfileService + { + private const string ProfilesSubdirectory = "profiles"; + private const string ProfileFileExtension = ".json"; + + [NotNull] + private static readonly JsonSerializerSettings s_jsonSettings = new JsonSerializerSettings + { + Formatting = Formatting.Indented, + NullValueHandling = NullValueHandling.Include, + }; + + [NotNull] + private readonly string _profilesDirectory; + + public ProfileService([NotNull] string storageDirectory) + { + if (string.IsNullOrWhiteSpace(storageDirectory)) + { + throw new ArgumentException("Storage directory cannot be null or whitespace.", nameof(storageDirectory)); + } + + _profilesDirectory = Path.Combine(storageDirectory, ProfilesSubdirectory); + } + + /// Directory holding the per-profile JSON files. + [NotNull] + public string ProfilesDirectory => _profilesDirectory; + + /// + /// Converts a profile name into a safe filename (without extension). + /// Invalid filename characters and path separators become underscores. + /// + [NotNull] + public static string SanitizeProfileFileName([NotNull] string profileName) + { + if (string.IsNullOrWhiteSpace(profileName)) + { + throw new ArgumentException("Profile name cannot be null or whitespace.", nameof(profileName)); + } + + char[] invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = new System.Text.StringBuilder(profileName.Length); + foreach (char c in profileName.Trim()) + { + // Treat both separator styles as invalid on every OS so profile names + // sanitize identically on Windows and Linux. + bool invalid = c == '/' + || c == '\\' + || c == Path.DirectorySeparatorChar + || c == Path.AltDirectorySeparatorChar + || Array.IndexOf(invalidChars, c) >= 0; + _ = sanitized.Append(invalid ? '_' : c); + } + + string result = sanitized.ToString().Trim(); + + // Avoid names that are only dots/underscores or empty after sanitization. + if (result.Length == 0 || result.All(c => c == '.' || c == '_')) + { + throw new ArgumentException($"Profile name '{profileName}' does not produce a usable filename.", nameof(profileName)); + } + + return result; + } + + [NotNull] + private string GetProfileFilePath([NotNull] string profileName) => + Path.Combine(_profilesDirectory, SanitizeProfileFileName(profileName) + ProfileFileExtension); + + /// Returns all profiles on disk, ordered by name. Corrupt files are skipped. + [NotNull] + [ItemNotNull] + public List ListProfiles() + { + var profiles = new List(); + if (!Directory.Exists(_profilesDirectory)) + { + return profiles; + } + + foreach (string filePath in Directory.GetFiles(_profilesDirectory, "*" + ProfileFileExtension)) + { + try + { + Profile profile = JsonConvert.DeserializeObject(File.ReadAllText(filePath), s_jsonSettings); + if (profile != null && !string.IsNullOrWhiteSpace(profile.Name)) + { + profiles.Add(profile); + } + } + catch (Exception ex) + { + Logger.LogWarning($"[ProfileService.ListProfiles] Skipping unreadable profile file '{filePath}': {ex.Message}"); + } + } + + return profiles.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase).ToList(); + } + + /// Loads a single profile by name, or null when it does not exist or cannot be read. + [CanBeNull] + public Profile LoadProfile([NotNull] string profileName) + { + string filePath = GetProfileFilePath(profileName); + if (!File.Exists(filePath)) + { + return null; + } + + try + { + return JsonConvert.DeserializeObject(File.ReadAllText(filePath), s_jsonSettings); + } + catch (Exception ex) + { + Logger.LogWarning($"[ProfileService.LoadProfile] Failed to read profile '{profileName}': {ex.Message}"); + return null; + } + } + + /// Persists a profile atomically (temp file in the same directory, then move). + public void SaveProfile([NotNull] Profile profile) + { + if (profile is null) + { + throw new ArgumentNullException(nameof(profile)); + } + + string filePath = GetProfileFilePath(profile.Name); + if (!Directory.Exists(_profilesDirectory)) + { + _ = Directory.CreateDirectory(_profilesDirectory); + } + + string json = JsonConvert.SerializeObject(profile, s_jsonSettings); + string tempPath = filePath + "." + Guid.NewGuid().ToString("N") + ".tmp"; + File.WriteAllText(tempPath, json); + try + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + + File.Move(tempPath, filePath); + } + catch + { + try + { + File.Delete(tempPath); + } + catch + { + // Leave the temp file behind if cleanup also fails; nothing else to do. + } + + throw; + } + } + + /// Creates and persists a new, empty profile. Fails when the name is already taken. + [NotNull] + public Profile CreateProfile([NotNull] string profileName) + { + string filePath = GetProfileFilePath(profileName); + if (File.Exists(filePath)) + { + throw new InvalidOperationException($"A profile named '{profileName}' already exists."); + } + + DateTime now = DateTime.UtcNow; + var profile = new Profile + { + Name = profileName.Trim(), + CreatedUtc = now, + LastUsedUtc = now, + }; + SaveProfile(profile); + return profile; + } + + /// Deep-copies an existing profile under a new name and persists the copy. + [NotNull] + public Profile CloneProfile([NotNull] Profile source, [NotNull] string newName) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source)); + } + + string filePath = GetProfileFilePath(newName); + if (File.Exists(filePath)) + { + throw new InvalidOperationException($"A profile named '{newName}' already exists."); + } + + // Round-trip through JSON for a simple deep copy. + Profile clone = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(source, s_jsonSettings), s_jsonSettings) + ?? throw new InvalidOperationException($"Failed to clone profile '{source.Name}'."); + clone.Name = newName.Trim(); + clone.CreatedUtc = DateTime.UtcNow; + SaveProfile(clone); + return clone; + } + + /// Renames a profile on disk (saves under the new name, removes the old file). + [NotNull] + public Profile RenameProfile([NotNull] string oldName, [NotNull] string newName) + { + string newPath = GetProfileFilePath(newName); + string oldPath = GetProfileFilePath(oldName); + if (string.Equals(oldPath, newPath, StringComparison.Ordinal)) + { + Profile unchanged = LoadProfile(oldName) + ?? throw new InvalidOperationException($"Profile '{oldName}' does not exist."); + unchanged.Name = newName.Trim(); + SaveProfile(unchanged); + return unchanged; + } + + if (File.Exists(newPath)) + { + throw new InvalidOperationException($"A profile named '{newName}' already exists."); + } + + Profile profile = LoadProfile(oldName) + ?? throw new InvalidOperationException($"Profile '{oldName}' does not exist."); + profile.Name = newName.Trim(); + SaveProfile(profile); + File.Delete(oldPath); + return profile; + } + + /// Deletes a profile file. Returns false when no such profile exists. + public bool DeleteProfile([NotNull] string profileName) + { + string filePath = GetProfileFilePath(profileName); + if (!File.Exists(filePath)) + { + return false; + } + + File.Delete(filePath); + return true; + } + + /// + /// Builds and persists a profile from the current application state: + /// / plus the + /// selection state of the given components and their options. + /// + [NotNull] + public Profile CaptureFromCurrentState( + [NotNull] string profileName, + [NotNull][ItemNotNull] IEnumerable components, + [CanBeNull] string instructionFilePath = null) + { + if (components is null) + { + throw new ArgumentNullException(nameof(components)); + } + + DateTime now = DateTime.UtcNow; + Profile existing = LoadProfile(profileName); + var profile = new Profile + { + Name = profileName.Trim(), + KotorDirectory = MainConfig.DestinationPath?.FullName, + ModDirectory = MainConfig.SourcePath?.FullName, + InstructionFilePath = instructionFilePath, + CreatedUtc = existing?.CreatedUtc ?? now, + LastUsedUtc = now, + }; + + foreach (ModComponent component in components) + { + var selection = new ProfileComponentSelection + { + IsSelected = component.IsSelected, + }; + foreach (Option option in component.Options) + { + if (option.IsSelected) + { + selection.SelectedOptionGuids.Add(option.Guid); + } + } + + profile.ComponentSelections[component.Guid] = selection; + } + + SaveProfile(profile); + return profile; + } + + /// + /// Activates a profile: writes its directories into (via the + /// instance accessors) and applies component/option IsSelected flags to the given + /// components. Components without an entry in the profile are left untouched. Updates + /// and persists the profile. + /// + public void ApplyProfile([NotNull] Profile profile, [NotNull][ItemNotNull] IEnumerable components) + { + if (profile is null) + { + throw new ArgumentNullException(nameof(profile)); + } + + if (components is null) + { + throw new ArgumentNullException(nameof(components)); + } + + if (!string.IsNullOrEmpty(profile.ModDirectory)) + { + MainConfig.Instance.sourcePath = new DirectoryInfo(profile.ModDirectory); + } + + if (!string.IsNullOrEmpty(profile.KotorDirectory)) + { + MainConfig.Instance.destinationPath = new DirectoryInfo(profile.KotorDirectory); + } + + foreach (ModComponent component in components) + { + if (!profile.ComponentSelections.TryGetValue(component.Guid, out ProfileComponentSelection selection)) + { + continue; + } + + component.IsSelected = selection.IsSelected; + foreach (Option option in component.Options) + { + option.IsSelected = selection.SelectedOptionGuids.Contains(option.Guid); + } + } + + profile.LastUsedUtc = DateTime.UtcNow; + SaveProfile(profile); + } + } +} diff --git a/src/ModSync.GUI/Dialogs/ProfileManagerDialog.axaml b/src/ModSync.GUI/Dialogs/ProfileManagerDialog.axaml new file mode 100644 index 00000000..a2dd47a2 --- /dev/null +++ b/src/ModSync.GUI/Dialogs/ProfileManagerDialog.axaml @@ -0,0 +1,107 @@ + + + + + + + + +