using Dalamud.Interface.ImGuiNotification; using OtterGui.Classes; using OtterGui.Extensions; using OtterGui.Services; using Penumbra.Communication; using Penumbra.Mods; using Penumbra.Mods.Editor; using Penumbra.Mods.Groups; using Penumbra.Mods.Manager; using Penumbra.Mods.Manager.OptionEditor; using Penumbra.Mods.Settings; using Penumbra.Mods.SubMods; using Penumbra.Services; namespace Penumbra.Collections.Manager; /// A contiguously incrementing ID managed by the CollectionCreator. public readonly record struct LocalCollectionId(int Id) : IAdditionOperators { public static readonly LocalCollectionId Zero = new(0); public static LocalCollectionId operator +(LocalCollectionId left, int right) => new(left.Id + right); } public class CollectionStorage : IReadOnlyList, IDisposable, IService { private readonly CommunicatorService _communicator; private readonly SaveService _saveService; private readonly ModStorage _modStorage; public ModCollection Create(string name, int index, ModCollection? duplicate) { var newCollection = duplicate?.Duplicate(name, CurrentCollectionId, index) ?? ModCollection.CreateEmpty(name, CurrentCollectionId, index, _modStorage.Count); Add(newCollection); return newCollection; } public ModCollection CreateFromData(Guid id, string name, int version, Dictionary allSettings, IReadOnlyList inheritances) { var newCollection = ModCollection.CreateFromData(_saveService, _modStorage, new ModCollectionIdentity(id, CurrentCollectionId, name, Count), version, allSettings, inheritances); Add(newCollection); return newCollection; } public ModCollection CreateTemporary(string name, int index, int globalChangeCounter) { var newCollection = ModCollection.CreateTemporary(name, CurrentCollectionId, index, globalChangeCounter); Add(newCollection); return newCollection; } /// Atomically add to _collectionLocal and increments _currentCollectionIdValue. private void Add(ModCollection newCollection) { _collectionsByLocal.AddOrUpdate(CurrentCollectionId, static (_, newColl) => newColl, static (_, _, newColl) => newColl, newCollection); Interlocked.Increment(ref _currentCollectionIdValue); } public void Delete(ModCollection collection) => _collectionsByLocal.TryRemove(collection.Identity.LocalId, out _); /// The empty collection is always available at Index 0. private readonly List _collections = [ ModCollection.Empty, ]; private readonly Lock _collectionsLock = new(); /// A list of all collections ever created still existing by their local id. private readonly ConcurrentDictionary _collectionsByLocal = new() { [LocalCollectionId.Zero] = ModCollection.Empty }; public readonly ModCollection DefaultNamed; /// Starts at 1 because the empty collection gets Zero. private int _currentCollectionIdValue = 1; /// Starts at 1 because the empty collection gets Zero. public LocalCollectionId CurrentCollectionId => new(_currentCollectionIdValue); /// Default enumeration skips the empty collection. public IEnumerator GetEnumerator() => _collections.Skip(1).GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); public int Count => _collections.Count; public ModCollection this[int index] => _collections[index]; /// Find a collection by its name. If the name is empty or None, the empty collection is returned. public bool ByName(string name, [NotNullWhen(true)] out ModCollection? collection) { if (name.Length != 0) return _collections.FindFirst(c => string.Equals(c.Identity.Name, name, StringComparison.OrdinalIgnoreCase), out collection); collection = ModCollection.Empty; return true; } /// Find a collection by its id. If the GUID is empty, the empty collection is returned. public bool ById(Guid id, [NotNullWhen(true)] out ModCollection? collection) { if (id != Guid.Empty) return _collections.FindFirst(c => c.Identity.Id == id, out collection); collection = ModCollection.Empty; return true; } /// Find a collection by an identifier, which is interpreted as a GUID first and if it does not correspond to one, as a name. public bool ByIdentifier(string identifier, [NotNullWhen(true)] out ModCollection? collection) { if (Guid.TryParse(identifier, out var guid)) return ById(guid, out collection); return ByName(identifier, out collection); } /// Find a collection by its local ID if it still exists, otherwise returns the empty collection. public ModCollection ByLocalId(LocalCollectionId localId) => _collectionsByLocal.TryGetValue(localId, out var coll) ? coll : ModCollection.Empty; public CollectionStorage(CommunicatorService communicator, SaveService saveService, ModStorage modStorage) { _communicator = communicator; _saveService = saveService; _modStorage = modStorage; _communicator.ModDiscoveryStarted.Subscribe(OnModDiscoveryStarted, ModDiscoveryStarted.Priority.CollectionStorage); _communicator.ModDiscoveryFinished.Subscribe(OnModDiscoveryFinished, ModDiscoveryFinished.Priority.CollectionStorage); _communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.CollectionStorage); _communicator.ModOptionChanged.Subscribe(OnModOptionChange, ModOptionChanged.Priority.CollectionStorage); _communicator.ModFileChanged.Subscribe(OnModFileChanged, ModFileChanged.Priority.CollectionStorage); ReadCollections(out DefaultNamed); } public void Dispose() { _communicator.ModDiscoveryStarted.Unsubscribe(OnModDiscoveryStarted); _communicator.ModDiscoveryFinished.Unsubscribe(OnModDiscoveryFinished); _communicator.ModPathChanged.Unsubscribe(OnModPathChange); _communicator.ModOptionChanged.Unsubscribe(OnModOptionChange); _communicator.ModFileChanged.Unsubscribe(OnModFileChanged); } /// /// Add a new collection of the given name. /// If duplicate is not-null, the new collection will be a duplicate of it. /// If the name of the collection would result in an already existing filename, skip it. /// Returns true if the collection was successfully created and fires a Inactive event. /// Also sets the current collection to the new collection afterwards. /// public bool AddCollection(string name, ModCollection? duplicate) { if (name.Length == 0) return false; ModCollection newCollection; lock (_collectionsLock) { newCollection = Create(name, _collections.Count, duplicate); _collections.Add(newCollection); } _saveService.ImmediateSave(new ModCollectionSave(_modStorage, newCollection)); Penumbra.Messager.NotificationMessage($"Created new collection {newCollection.Identity.AnonymizedName}.", NotificationType.Success, false); _communicator.CollectionChange.Invoke(CollectionType.Inactive, null, newCollection, string.Empty); return true; } /// /// Remove the given collection if it exists and is neither the empty nor the default-named collection. /// public bool RemoveCollection(ModCollection collection) { if (collection.Identity.Index <= ModCollection.Empty.Identity.Index || collection.Identity.Index >= _collections.Count) { Penumbra.Messager.NotificationMessage("Can not remove the empty collection.", NotificationType.Error, false); return false; } if (collection.Identity.Index == DefaultNamed.Identity.Index) { Penumbra.Messager.NotificationMessage("Can not remove the default collection.", NotificationType.Error, false); return false; } Delete(collection); _saveService.ImmediateDelete(new ModCollectionSave(_modStorage, collection)); lock (_collectionsLock) { _collections.RemoveAt(collection.Identity.Index); // Update indices. for (var i = collection.Identity.Index; i < Count; ++i) _collections[i].Identity.Index = i; } Penumbra.Messager.NotificationMessage($"Deleted collection {collection.Identity.AnonymizedName}.", NotificationType.Success, false); _communicator.CollectionChange.Invoke(CollectionType.Inactive, collection, null, string.Empty); return true; } /// Remove all settings for not currently-installed mods from the given collection. public int CleanUnavailableSettings(ModCollection collection) { var count = collection.Settings.Unused.Count; if (count > 0) { ((Dictionary)collection.Settings.Unused).Clear(); _saveService.QueueSave(new ModCollectionSave(_modStorage, collection)); } return count; } /// Remove a specific setting for not currently-installed mods from the given collection. public void CleanUnavailableSetting(ModCollection collection, string? setting) { if (setting != null && ((Dictionary)collection.Settings.Unused).Remove(setting)) _saveService.QueueSave(new ModCollectionSave(_modStorage, collection)); } /// /// Read all collection files in the Collection Directory. /// Ensure that the default named collection exists, and apply inheritances afterward. /// Duplicate collection files are not deleted, just not added here. /// private void ReadCollections(out ModCollection defaultNamedCollection) { Penumbra.Log.Debug("[Collections] Reading saved collections..."); foreach (var file in _saveService.FileNames.CollectionFiles) { if (!ModCollectionSave.LoadFromFile(file, out var id, out var name, out var version, out var settings, out var inheritance)) continue; if (id == Guid.Empty) { Penumbra.Messager.NotificationMessage("Collection without ID found.", NotificationType.Warning); continue; } if (ById(id, out _)) { Penumbra.Messager.NotificationMessage($"Duplicate collection found: {id} already exists. Import skipped.", NotificationType.Warning); continue; } var collection = CreateFromData(id, name, version, settings, inheritance); var correctName = _saveService.FileNames.CollectionFile(collection); if (file.FullName != correctName) try { if (version >= 2) { try { File.Move(file.FullName, correctName, false); Penumbra.Messager.NotificationMessage( $"Collection {file.Name} does not correspond to {collection.Identity.Identifier}, renamed.", NotificationType.Warning); } catch (Exception ex) { Penumbra.Messager.NotificationMessage( $"Collection {file.Name} does not correspond to {collection.Identity.Identifier}, rename failed:\n{ex}", NotificationType.Warning); } } else { _saveService.ImmediateSaveSync(new ModCollectionSave(_modStorage, collection)); try { File.Move(file.FullName, file.FullName + ".bak", true); Penumbra.Log.Information($"Migrated collection {name} to Guid {id} with backup of old file."); } catch (Exception ex) { Penumbra.Log.Information($"Migrated collection {name} to Guid {id}, rename of old file failed:\n{ex}"); } } } catch (Exception e) { Penumbra.Messager.NotificationMessage(e, $"Collection {file.Name} does not correspond to {collection.Identity.Identifier}, but could not rename.", NotificationType.Error); } _collections.Add(collection); } defaultNamedCollection = SetDefaultNamedCollection(); Penumbra.Log.Debug($"[Collections] Found {Count} saved collections."); } /// /// Add the collection with the default name if it does not exist. /// It should always be ensured that it exists, otherwise it will be created. /// This can also not be deleted, so there are always at least the empty and a collection with default name. /// private ModCollection SetDefaultNamedCollection() { if (ByName(ModCollectionIdentity.DefaultCollectionName, out var collection)) return collection; if (AddCollection(ModCollectionIdentity.DefaultCollectionName, null)) return _collections[^1]; Penumbra.Messager.NotificationMessage( $"Unknown problem creating a collection with the name {ModCollectionIdentity.DefaultCollectionName}, which is required to exist.", NotificationType.Error); return Count > 1 ? _collections[1] : _collections[0]; } /// Move all settings in all collections to unused settings. private void OnModDiscoveryStarted() { var snapshot = GetModSnapShot(); foreach (var collection in snapshot) collection.Settings.PrepareModDiscovery(_modStorage); } /// Restore all settings in all collections to mods. private void OnModDiscoveryFinished() { var snapshot = GetModSnapShot(); // Re-apply all mod settings. foreach (var collection in snapshot) collection.Settings.ApplyModSettings(collection, _saveService, _modStorage); } /// Add or remove a mod from all collections, or re-save all collections where the mod has settings. private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? oldDirectory, DirectoryInfo? newDirectory) { var snapshot = GetModSnapShot(); switch (type) { case ModPathChangeType.Added: foreach (var collection in snapshot) collection.Settings.AddMod(mod); break; case ModPathChangeType.Deleted: foreach (var collection in snapshot) collection.Settings.RemoveMod(mod); break; case ModPathChangeType.Moved: foreach (var collection in snapshot.Where(collection => collection.GetOwnSettings(mod.Index) != null)) _saveService.QueueSave(new ModCollectionSave(_modStorage, collection)); break; case ModPathChangeType.Reloaded: foreach (var collection in snapshot) { if (collection.GetOwnSettings(mod.Index)?.Settings.FixAll(mod) ?? false) _saveService.QueueSave(new ModCollectionSave(_modStorage, collection)); collection.Settings.SetTemporary(mod.Index, null); } break; } } /// Save all collections where the mod has settings and the change requires saving. private void OnModOptionChange(ModOptionChangeType type, Mod mod, IModGroup? group, IModOption? option, IModDataContainer? container, int movedToIdx) { type.HandlingInfo(out var requiresSaving, out _, out _); if (!requiresSaving) return; var snapshot = GetModSnapShot(); foreach (var collection in snapshot) { if (collection.GetOwnSettings(mod.Index)?.HandleChanges(type, mod, group, option, movedToIdx) ?? false) _saveService.QueueSave(new ModCollectionSave(_modStorage, collection)); collection.Settings.SetTemporary(mod.Index, null); } } /// Update change counters when changing files. private void OnModFileChanged(Mod mod, FileRegistry file) { if (file.CurrentUsage == 0) return; var snapshot = GetModSnapShot(); foreach (var collection in snapshot) { var (settings, _) = collection.GetActualSettings(mod.Index); if (settings is { Enabled: true }) collection.Counters.IncrementChange(); } } private ModCollection[] GetModSnapShot() { ModCollection[] snapshot; lock (_collectionsLock) { snapshot = _collections.Skip(1).ToArray(); } return snapshot; } }