Add start of mod merger.

This commit is contained in:
Ottermandias 2023-05-05 16:19:08 +02:00
parent 8e5ed60c79
commit d403f44256
12 changed files with 538 additions and 20 deletions

View file

@ -8,22 +8,19 @@ using System.Threading.Tasks;
using Penumbra.Mods.Manager;
using Penumbra.Services;
using Penumbra.String.Classes;
using Penumbra.Util;
namespace Penumbra.Mods;
namespace Penumbra.Mods.Editor;
public class DuplicateManager
{
private readonly SaveService _saveService;
private readonly ModManager _modManager;
private readonly SHA256 _hasher = SHA256.Create();
private readonly ModFileCollection _files;
private readonly List<(FullPath[] Paths, long Size, byte[] Hash)> _duplicates = new();
public DuplicateManager(ModFileCollection files, ModManager modManager, SaveService saveService)
public DuplicateManager(ModManager modManager, SaveService saveService)
{
_files = files;
_modManager = modManager;
_modManager = modManager;
_saveService = saveService;
}
@ -43,7 +40,7 @@ public class DuplicateManager
Task.Run(() => CheckDuplicates(filesTmp));
}
public void DeleteDuplicates(Mod mod, ISubMod option, bool useModManager)
public void DeleteDuplicates(ModFileCollection files, Mod mod, ISubMod option, bool useModManager)
{
if (!Finished || _duplicates.Count == 0)
return;
@ -60,7 +57,7 @@ public class DuplicateManager
_duplicates.Clear();
DeleteEmptyDirectories(mod.ModPath);
_files.UpdateAll(mod, option);
files.UpdateAll(mod, option);
}
public void Clear()
@ -248,9 +245,10 @@ public class DuplicateManager
_modManager.Creator.ReloadMod(mod, true, out _);
Finished = false;
_files.UpdateAll(mod, mod.Default);
CheckDuplicates(_files.Available.OrderByDescending(f => f.FileSize).ToArray());
DeleteDuplicates(mod, mod.Default, false);
var files = new ModFileCollection();
files.UpdateAll(mod, mod.Default);
CheckDuplicates(files.Available.OrderByDescending(f => f.FileSize).ToArray());
DeleteDuplicates(files, mod, mod.Default, false);
}
catch (Exception e)
{

View file

@ -7,6 +7,7 @@ using System.Text.RegularExpressions;
using OtterGui;
using Penumbra.GameData.Enums;
using Penumbra.GameData.Files;
using Penumbra.Mods.Editor;
namespace Penumbra.Mods;

View file

@ -1,6 +1,7 @@
using System;
using System.IO;
using OtterGui;
using Penumbra.Mods.Editor;
namespace Penumbra.Mods;

View file

@ -3,11 +3,10 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using Microsoft.Win32;
using OtterGui;
using Penumbra.String.Classes;
namespace Penumbra.Mods;
namespace Penumbra.Mods.Editor;
public class ModFileCollection : IDisposable
{

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Penumbra.Mods.Editor;
using Penumbra.Mods.Manager;
using Penumbra.String.Classes;

View file

@ -0,0 +1,361 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using Dalamud.Interface.Internal.Notifications;
using Dalamud.Utility;
using OtterGui;
using Penumbra.Api.Enums;
using Penumbra.Communication;
using Penumbra.Mods.Manager;
using Penumbra.Services;
using Penumbra.String.Classes;
using Penumbra.UI.ModsTab;
namespace Penumbra.Mods.Editor;
public class ModMerger : IDisposable
{
private readonly CommunicatorService _communicator;
private readonly ModOptionEditor _editor;
private readonly ModFileSystemSelector _selector;
private readonly DuplicateManager _duplicates;
private readonly ModManager _mods;
private readonly ModCreator _creator;
public Mod? MergeFromMod { get; private set; }
public Mod? MergeToMod;
public string OptionGroupName = "Merges";
public string OptionName = string.Empty;
private readonly Dictionary<string, string> _fileToFile = new();
private readonly HashSet<string> _createdDirectories = new();
public readonly HashSet<SubMod> SelectedOptions = new();
private int _createdGroup = -1;
private SubMod? _createdOption;
public Exception? Error { get; private set; }
public ModMerger(ModManager mods, ModOptionEditor editor, ModFileSystemSelector selector, DuplicateManager duplicates,
CommunicatorService communicator, ModCreator creator)
{
_editor = editor;
_selector = selector;
_duplicates = duplicates;
_communicator = communicator;
_creator = creator;
_mods = mods;
_selector.SelectionChanged += OnSelectionChange;
_communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.Api);
}
public void Dispose()
{
_selector.SelectionChanged -= OnSelectionChange;
_communicator.ModPathChanged.Unsubscribe(OnModPathChange);
}
public IEnumerable<Mod> ModsWithoutCurrent
=> _mods.Where(m => m != MergeFromMod);
public bool CanMerge
=> MergeToMod != null && MergeToMod != MergeFromMod && !MergeFromMod!.HasOptions;
public void Merge()
{
if (MergeFromMod == null || MergeToMod == null || MergeFromMod == MergeToMod)
return;
try
{
Error = null;
DataCleanup();
if (MergeFromMod.HasOptions)
MergeWithOptions();
else
MergeIntoOption(OptionGroupName, OptionName);
_duplicates.DeduplicateMod(MergeToMod.ModPath);
}
catch (Exception ex)
{
Error = ex;
Penumbra.ChatService.NotificationMessage(
$"Could not merge {MergeFromMod!.Name} into {MergeToMod!.Name}, cleaning up changes.:\n{ex}", "Failure",
NotificationType.Error);
FailureCleanup();
DataCleanup();
}
}
private void MergeWithOptions()
{
// Not supported
}
private void MergeIntoOption(string groupName, string optionName)
{
if (groupName.Length == 0 && optionName.Length == 0)
{
CopyFiles(MergeToMod!.ModPath);
MergeIntoOption(MergeFromMod!.AllSubMods.Reverse(), MergeToMod!.Default);
}
else if (groupName.Length * optionName.Length == 0)
{
return;
}
var (group, groupIdx, groupCreated) = _editor.FindOrAddModGroup(MergeToMod!, GroupType.Multi, groupName);
if (groupCreated)
_createdGroup = groupIdx;
var (option, optionCreated) = _editor.FindOrAddOption(MergeToMod!, groupIdx, optionName);
if (optionCreated)
_createdOption = option;
var dir = ModCreator.NewOptionDirectory(MergeToMod!.ModPath, groupName);
if (!dir.Exists)
_createdDirectories.Add(dir.FullName);
dir = ModCreator.NewOptionDirectory(dir, optionName);
if (!dir.Exists)
_createdDirectories.Add(dir.FullName);
CopyFiles(dir);
MergeIntoOption(MergeFromMod!.AllSubMods.Reverse(), option);
}
private void MergeIntoOption(IEnumerable<SubMod> mergeOptions, SubMod option)
{
var redirections = option.FileData.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
var swaps = option.FileSwapData.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
var manips = option.ManipulationData.ToHashSet();
foreach (var originalOption in mergeOptions)
{
foreach (var manip in originalOption.Manipulations)
{
if (!manips.Add(manip))
throw new Exception(
$"Could not add meta manipulation {manip} from {originalOption.FullName} to {option.FullName} because another manipulation of the same data already exists in this option.");
}
foreach (var (swapA, swapB) in originalOption.FileSwaps)
{
if (!swaps.TryAdd(swapA, swapB))
throw new Exception(
$"Could not add file swap {swapB} -> {swapA} from {originalOption.FullName} to {option.FullName} because another swap of the key already exists.");
}
foreach (var (gamePath, relPath) in originalOption.Files)
{
if (!_fileToFile.TryGetValue(relPath.FullName, out var newFile))
throw new Exception(
$"Could not add file redirection {relPath} -> {gamePath} from {originalOption.FullName} to {option.FullName} because the file does not exist in the new mod.");
if (!redirections.TryAdd(gamePath, new FullPath(newFile)))
throw new Exception(
$"Could not add file redirection {relPath} -> {gamePath} from {originalOption.FullName} to {option.FullName} because a redirection for the game path already exists.");
}
}
_editor.OptionSetFiles(MergeToMod!, option.GroupIdx, option.OptionIdx, redirections);
_editor.OptionSetFileSwaps(MergeToMod!, option.GroupIdx, option.OptionIdx, swaps);
_editor.OptionSetManipulations(MergeToMod!, option.GroupIdx, option.OptionIdx, manips);
}
private void CopyFiles(DirectoryInfo directory)
{
directory = Directory.CreateDirectory(directory.FullName);
foreach (var file in MergeFromMod!.ModPath.EnumerateDirectories()
.Where(d => !d.IsHidden())
.SelectMany(FileExtensions.EnumerateNonHiddenFiles))
{
var path = Path.GetRelativePath(MergeFromMod.ModPath.FullName, file.FullName);
path = Path.Combine(directory.FullName, path);
var finalDir = Path.GetDirectoryName(path)!;
var dir = finalDir;
while (!dir.IsNullOrEmpty())
{
if (!Directory.Exists(dir))
_createdDirectories.Add(dir);
else
break;
dir = Path.GetDirectoryName(dir);
}
Directory.CreateDirectory(finalDir);
file.CopyTo(path);
Penumbra.Log.Verbose($"[Merger] Copied file {file.FullName} to {path}.");
_fileToFile.Add(file.FullName, path);
}
}
public void SplitIntoMod(string modName)
{
var mods = SelectedOptions.ToList();
if (mods.Count == 0)
return;
Error = null;
DirectoryInfo? dir = null;
Mod? result = null;
try
{
dir = _creator.CreateEmptyMod(_mods.BasePath, modName, $"Split off from {mods[0].ParentMod.Name}.");
if (dir == null)
throw new Exception($"Could not split off mods, unable to create new mod with name {modName}.");
_mods.AddMod(dir);
result = _mods[^1];
if (mods.Count == 1)
{
var files = CopySubModFiles(mods[0], dir);
_editor.OptionSetFiles(result, -1, 0, files);
_editor.OptionSetFileSwaps(result, -1, 0, mods[0].FileSwapData);
_editor.OptionSetManipulations(result, -1, 0, mods[0].ManipulationData);
}
else
{
foreach (var originalOption in mods)
{
var originalGroup = originalOption.ParentMod.Groups[originalOption.GroupIdx];
if (originalOption.IsDefault)
{
var files = CopySubModFiles(mods[0], dir);
_editor.OptionSetFiles(result, -1, 0, files);
_editor.OptionSetFileSwaps(result, -1, 0, mods[0].FileSwapData);
_editor.OptionSetManipulations(result, -1, 0, mods[0].ManipulationData);
}
else
{
var (group, groupIdx, _) = _editor.FindOrAddModGroup(result, originalGroup.Type, originalGroup.Name);
var (option, _) = _editor.FindOrAddOption(result, groupIdx, originalOption.Name);
var folder = Path.Combine(dir.FullName, group.Name, option.Name);
var files = CopySubModFiles(originalOption, new DirectoryInfo(folder));
_editor.OptionSetFiles(result, groupIdx, option.OptionIdx, files);
_editor.OptionSetFileSwaps(result, groupIdx, option.OptionIdx, originalOption.FileSwapData);
_editor.OptionSetManipulations(result, groupIdx, option.OptionIdx, originalOption.ManipulationData);
}
}
}
}
catch (Exception e)
{
Error = e;
if (result != null)
_mods.DeleteMod(result);
else if (dir != null)
try
{
Directory.Delete(dir.FullName);
}
catch (Exception ex)
{
Penumbra.Log.Error($"Could not clean up after failure to split options into new mod {modName}:\n{ex}");
}
}
}
private static Dictionary<Utf8GamePath, FullPath> CopySubModFiles(SubMod option, DirectoryInfo newMod)
{
var ret = new Dictionary<Utf8GamePath, FullPath>(option.FileData.Count);
var parentPath = ((Mod)option.ParentMod).ModPath.FullName;
foreach (var (path, file) in option.FileData)
{
var target = Path.GetRelativePath(parentPath, file.FullName);
target = Path.Combine(newMod.FullName, target);
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
File.Copy(file.FullName, target);
Penumbra.Log.Verbose($"[Splitter] Copied file {file.FullName} to {target}.");
ret.Add(path, new FullPath(target));
}
return ret;
}
private void DataCleanup()
{
_fileToFile.Clear();
_createdDirectories.Clear();
_createdOption = null;
_createdGroup = -1;
}
private void FailureCleanup()
{
if (_createdGroup >= 0 && _createdGroup < MergeToMod!.Groups.Count)
_editor.DeleteModGroup(MergeToMod!, _createdGroup);
else if (_createdOption != null)
_editor.DeleteOption(MergeToMod!, _createdOption.GroupIdx, _createdOption.OptionIdx);
foreach (var dir in _createdDirectories)
{
if (!Directory.Exists(dir))
continue;
try
{
Directory.Delete(dir, true);
Penumbra.Log.Verbose($"[Merger] Deleted {dir}.");
}
catch (Exception ex)
{
Penumbra.Log.Error(
$"Could not clean up after failing to merge {MergeFromMod!.Name} into {MergeToMod!.Name}, unable to delete {dir}:\n{ex}");
}
}
foreach (var (_, file) in _fileToFile)
{
if (!File.Exists(file))
continue;
try
{
File.Delete(file);
Penumbra.Log.Verbose($"[Merger] Deleted {file}.");
}
catch (Exception ex)
{
Penumbra.Log.Error(
$"Could not clean up after failing to merge {MergeFromMod!.Name} into {MergeToMod!.Name}, unable to delete {file}:\n{ex}");
}
}
}
private void OnSelectionChange(Mod? oldSelection, Mod? newSelection, in ModFileSystemSelector.ModState state)
{
if (OptionGroupName == "Merges" && OptionName.Length == 0 || OptionName == oldSelection?.Name.Text)
OptionName = newSelection?.Name.Text ?? string.Empty;
if (MergeToMod == newSelection)
MergeToMod = null;
SelectedOptions.Clear();
MergeFromMod = newSelection;
}
private void OnModPathChange(ModPathChangeType type, Mod mod, DirectoryInfo? _1, DirectoryInfo? _2)
{
switch (type)
{
case ModPathChangeType.Deleted:
{
if (mod == MergeFromMod)
{
SelectedOptions.Clear();
MergeFromMod = null;
}
if (mod == MergeToMod)
MergeToMod = null;
break;
}
case ModPathChangeType.StartingReload:
SelectedOptions.Clear();
MergeFromMod = null;
MergeToMod = null;
break;
case ModPathChangeType.Reloaded:
MergeFromMod = _selector.Selected;
break;
}
}
}

View file

@ -87,7 +87,7 @@ public class ModOptionEditor
_communicator.ModOptionChanged.Invoke(ModOptionChangeType.GroupRenamed, mod, groupIdx, -1, -1);
}
/// <summary> Add a new mod, empty option group of the given type and name. </summary>
/// <summary> Add a new, empty option group of the given type and name. </summary>
public void AddModGroup(Mod mod, GroupType type, string newName)
{
if (!VerifyFileName(mod, null, newName, true))
@ -110,6 +110,20 @@ public class ModOptionEditor
_communicator.ModOptionChanged.Invoke(ModOptionChangeType.GroupAdded, mod, mod.Groups.Count - 1, -1, -1);
}
/// <summary> Add a new mod, empty option group of the given type and name if it does not exist already. </summary>
public (IModGroup, int, bool) FindOrAddModGroup(Mod mod, GroupType type, string newName)
{
var idx = mod.Groups.IndexOf(g => g.Name == newName);
if (idx >= 0)
return (mod.Groups[idx], idx, false);
AddModGroup(mod, type, newName);
if (mod.Groups[^1].Name != newName)
throw new Exception($"Could not create new mod group with name {newName}.");
return (mod.Groups[^1], mod.Groups.Count - 1, true);
}
/// <summary> Delete a given option group. Fires an event to prepare before actually deleting. </summary>
public void DeleteModGroup(Mod mod, int groupIdx)
{
@ -242,6 +256,21 @@ public class ModOptionEditor
_communicator.ModOptionChanged.Invoke(ModOptionChangeType.OptionAdded, mod, groupIdx, group.Count - 1, -1);
}
/// <summary> Add a new empty option of the given name for the given group if it does not exist already. </summary>
public (SubMod, bool) FindOrAddOption(Mod mod, int groupIdx, string newName)
{
var group = mod.Groups[groupIdx];
var idx = group.IndexOf(o => o.Name == newName);
if (idx >= 0)
return ((SubMod)group[idx], false);
AddOption(mod, groupIdx, newName);
if (group[^1].Name != newName)
throw new Exception($"Could not create new option with name {newName} in {group.Name}.");
return ((SubMod)group[^1], true);
}
/// <summary> Add an existing option to a given group with a given priority. </summary>
public void AddOption(Mod mod, int groupIdx, ISubMod option, int priority = 0)
{

View file

@ -2,9 +2,24 @@ using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using OtterGui.Classes;
using OtterGui.Widgets;
namespace Penumbra.Mods.Manager;
public class ModCombo : FilterComboCache<Mod>
{
protected override bool IsVisible(int globalIndex, LowerString filter)
=> Items[globalIndex].Name.Contains(filter);
protected override string ToString(Mod obj)
=> obj.Name.Text;
public ModCombo(Func<IReadOnlyList<Mod>> generator)
: base(generator)
{ }
}
public class ModStorage : IReadOnlyList<Mod>
{
/// <summary> The actual list of mods. </summary>