diff --git a/Penumbra/Mods/Editor/Mod.Editor.Groups.cs b/Penumbra/Mods/Editor/Mod.Editor.Groups.cs
deleted file mode 100644
index 2e1a4ab2..00000000
--- a/Penumbra/Mods/Editor/Mod.Editor.Groups.cs
+++ /dev/null
@@ -1,70 +0,0 @@
-namespace Penumbra.Mods;
-
-public partial class Mod
-{
- public partial class Editor
- {
- public void Normalize()
- {}
-
- public void AutoGenerateGroups()
- {
- //ClearEmptySubDirectories( _mod.BasePath );
- //for( var i = _mod.Groups.Count - 1; i >= 0; --i )
- //{
- // if (_mod.Groups.)
- // Penumbra.ModManager.DeleteModGroup( _mod, i );
- //}
- //Penumbra.ModManager.OptionSetFiles( _mod, -1, 0, new Dictionary< Utf8GamePath, FullPath >() );
- //
- //foreach( var groupDir in _mod.BasePath.EnumerateDirectories() )
- //{
- // var groupName = groupDir.Name;
- // foreach( var optionDir in groupDir.EnumerateDirectories() )
- // { }
- //}
-
- //var group = new OptionGroup
- // {
- // GroupName = groupDir.Name,
- // SelectionType = SelectType.Single,
- // Options = new List< Option >(),
- // };
- //
- // foreach( var optionDir in groupDir.EnumerateDirectories() )
- // {
- // var option = new Option
- // {
- // OptionDesc = string.Empty,
- // OptionName = optionDir.Name,
- // OptionFiles = new Dictionary< Utf8RelPath, HashSet< Utf8GamePath > >(),
- // };
- // foreach( var file in optionDir.EnumerateFiles( "*.*", SearchOption.AllDirectories ) )
- // {
- // if( Utf8RelPath.FromFile( file, baseDir, out var rel )
- // && Utf8GamePath.FromFile( file, optionDir, out var game ) )
- // {
- // option.OptionFiles[ rel ] = new HashSet< Utf8GamePath > { game };
- // }
- // }
- //
- // if( option.OptionFiles.Count > 0 )
- // {
- // group.Options.Add( option );
- // }
- // }
- //
- // if( group.Options.Count > 0 )
- // {
- // meta.Groups.Add( groupDir.Name, group );
- // }
- //}
- //
- //var idx = Penumbra.ModManager.Mods.IndexOf( m => m.Meta == meta );
- //foreach( var collection in Penumbra.CollectionManager )
- //{
- // collection.Settings[ idx ]?.FixInvalidSettings( meta );
- //}
- }
- }
-}
\ No newline at end of file
diff --git a/Penumbra/Mods/Editor/Mod.Normalization.cs b/Penumbra/Mods/Editor/Mod.Normalization.cs
new file mode 100644
index 00000000..73a92a09
--- /dev/null
+++ b/Penumbra/Mods/Editor/Mod.Normalization.cs
@@ -0,0 +1,262 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Dalamud.Interface.Internal.Notifications;
+using OtterGui;
+using Penumbra.String.Classes;
+using Penumbra.Util;
+
+namespace Penumbra.Mods;
+
+public partial class Mod
+{
+ public void Normalize( Manager manager )
+ => ModNormalizer.Normalize( manager, this );
+
+ private struct ModNormalizer
+ {
+ private readonly Mod _mod;
+ private readonly string _normalizationDirName;
+ private readonly string _oldDirName;
+ private Dictionary< Utf8GamePath, FullPath >[][]? _redirections = null;
+
+ private ModNormalizer( Mod mod )
+ {
+ _mod = mod;
+ _normalizationDirName = Path.Combine( _mod.ModPath.FullName, "TmpNormalization" );
+ _oldDirName = Path.Combine( _mod.ModPath.FullName, "TmpNormalizationOld" );
+ }
+
+ public static void Normalize( Manager manager, Mod mod )
+ {
+ var normalizer = new ModNormalizer( mod );
+ try
+ {
+ Penumbra.Log.Debug( $"[Normalization] Starting Normalization of {mod.ModPath.Name}..." );
+ if( !normalizer.CheckDirectories() )
+ {
+ return;
+ }
+
+ Penumbra.Log.Debug( "[Normalization] Copying files to temporary directory structure..." );
+ if( !normalizer.CopyNewFiles() )
+ {
+ return;
+ }
+
+ Penumbra.Log.Debug( "[Normalization] Moving old files out of the way..." );
+ if( !normalizer.MoveOldFiles() )
+ {
+ return;
+ }
+
+ Penumbra.Log.Debug( "[Normalization] Moving new directory structure in place..." );
+ if( !normalizer.MoveNewFiles() )
+ {
+ return;
+ }
+
+ Penumbra.Log.Debug( "[Normalization] Applying new redirections..." );
+ normalizer.ApplyRedirections( manager );
+ }
+ catch( Exception e )
+ {
+ ChatUtil.NotificationMessage( $"Could not normalize mod:\n{e}", "Failure", NotificationType.Error );
+ }
+ finally
+ {
+ Penumbra.Log.Debug( "[Normalization] Cleaning up remaining directories..." );
+ normalizer.Cleanup();
+ }
+ }
+
+ private bool CheckDirectories()
+ {
+ if( Directory.Exists( _normalizationDirName ) )
+ {
+ ChatUtil.NotificationMessage( "Could not normalize mod:\n"
+ + "The directory TmpNormalization may not already exist when normalizing a mod.", "Failure",
+ NotificationType.Error );
+ return false;
+ }
+
+ if( Directory.Exists( _oldDirName ) )
+ {
+ ChatUtil.NotificationMessage( "Could not normalize mod:\n"
+ + "The directory TmpNormalizationOld may not already exist when normalizing a mod.", "Failure",
+ NotificationType.Error );
+ return false;
+ }
+
+ return true;
+ }
+
+ private void Cleanup()
+ {
+ if( Directory.Exists( _normalizationDirName ) )
+ {
+ try
+ {
+ Directory.Delete( _normalizationDirName, true );
+ }
+ catch
+ {
+ // ignored
+ }
+ }
+
+ if( Directory.Exists( _oldDirName ) )
+ {
+ try
+ {
+ foreach( var dir in new DirectoryInfo( _oldDirName ).EnumerateDirectories() )
+ {
+ dir.MoveTo( Path.Combine( _mod.ModPath.FullName, dir.Name ) );
+ }
+
+ Directory.Delete( _oldDirName, true );
+ }
+ catch
+ {
+ // ignored
+ }
+ }
+ }
+
+ private bool CopyNewFiles()
+ {
+ // We copy all files to a temporary folder to ensure that we can revert the operation on failure.
+ try
+ {
+ var directory = Directory.CreateDirectory( _normalizationDirName );
+ _redirections = new Dictionary< Utf8GamePath, FullPath >[_mod.Groups.Count + 1][];
+ _redirections[ 0 ] = new Dictionary< Utf8GamePath, FullPath >[] { new(_mod.Default.Files.Count) };
+
+ // Normalize the default option.
+ var newDict = new Dictionary< Utf8GamePath, FullPath >( _mod.Default.Files.Count );
+ _redirections[ 0 ][ 0 ] = newDict;
+ foreach( var (gamePath, fullPath) in _mod._default.FileData )
+ {
+ var relPath = new Utf8RelPath( gamePath ).ToString();
+ var newFullPath = Path.Combine( directory.FullName, relPath );
+ var redirectPath = new FullPath( Path.Combine( _mod.ModPath.FullName, relPath ) );
+ Directory.CreateDirectory( Path.GetDirectoryName( newFullPath )! );
+ File.Copy( fullPath.FullName, newFullPath, true );
+ newDict.Add( gamePath, redirectPath );
+ }
+
+ // Normalize all other options.
+ foreach( var (group, groupIdx) in _mod.Groups.WithIndex() )
+ {
+ _redirections[ groupIdx + 1 ] = new Dictionary< Utf8GamePath, FullPath >[group.Count];
+ var groupDir = CreateModFolder( directory, group.Name );
+
+ foreach( var option in group.OfType< SubMod >() )
+ {
+ var optionDir = CreateModFolder( groupDir, option.Name );
+ newDict = new Dictionary< Utf8GamePath, FullPath >( option.FileData.Count );
+ _redirections[ groupIdx + 1 ][ option.OptionIdx ] = newDict;
+ foreach( var (gamePath, fullPath) in option.FileData )
+ {
+ var relPath = new Utf8RelPath( gamePath ).ToString();
+ var newFullPath = Path.Combine( optionDir.FullName, relPath );
+ var redirectPath = new FullPath( Path.Combine( _mod.ModPath.FullName, groupDir.Name, optionDir.Name, relPath ) );
+ Directory.CreateDirectory( Path.GetDirectoryName( newFullPath )! );
+ File.Copy( fullPath.FullName, newFullPath, true );
+ newDict.Add( gamePath, redirectPath );
+ }
+ }
+ }
+
+ return true;
+ }
+ catch( Exception e )
+ {
+ ChatUtil.NotificationMessage( $"Could not normalize mod:\n{e}", "Failure", NotificationType.Error );
+ _redirections = null;
+ }
+
+ return false;
+ }
+
+ private bool MoveOldFiles()
+ {
+ try
+ {
+ // Clean old directories and files.
+ var oldDirectory = Directory.CreateDirectory( _oldDirName );
+ foreach( var dir in _mod.ModPath.EnumerateDirectories() )
+ {
+ if( dir.FullName.Equals( _oldDirName, StringComparison.OrdinalIgnoreCase )
+ || dir.FullName.Equals( _normalizationDirName, StringComparison.OrdinalIgnoreCase ) )
+ {
+ continue;
+ }
+
+ dir.MoveTo( Path.Combine( oldDirectory.FullName, dir.Name ) );
+ }
+
+ return true;
+ }
+ catch( Exception e )
+ {
+ ChatUtil.NotificationMessage( $"Could not move old files out of the way while normalizing mod mod:\n{e}", "Failure", NotificationType.Error );
+ }
+
+ return false;
+ }
+
+ private bool MoveNewFiles()
+ {
+ try
+ {
+ var mainDir = new DirectoryInfo( _normalizationDirName );
+ foreach( var dir in mainDir.EnumerateDirectories() )
+ {
+ dir.MoveTo( Path.Combine( _mod.ModPath.FullName, dir.Name ) );
+ }
+
+ mainDir.Delete();
+ Directory.Delete( _oldDirName, true );
+ return true;
+ }
+ catch( Exception e )
+ {
+ ChatUtil.NotificationMessage( $"Could not move new files into the mod while normalizing mod mod:\n{e}", "Failure", NotificationType.Error );
+ foreach( var dir in _mod.ModPath.EnumerateDirectories() )
+ {
+ if( dir.FullName.Equals( _oldDirName, StringComparison.OrdinalIgnoreCase )
+ || dir.FullName.Equals( _normalizationDirName, StringComparison.OrdinalIgnoreCase ) )
+ {
+ continue;
+ }
+
+ try
+ {
+ dir.Delete( true );
+ }
+ catch
+ {
+ // ignored
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private void ApplyRedirections( Manager manager )
+ {
+ if( _redirections == null )
+ {
+ return;
+ }
+
+ foreach( var option in _mod.AllSubMods.OfType< SubMod >() )
+ {
+ manager.OptionSetFiles( _mod, option.GroupIdx, option.OptionIdx, _redirections[ option.GroupIdx + 1 ][ option.OptionIdx ] );
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Penumbra/Mods/Editor/ModCleanup.cs b/Penumbra/Mods/Editor/ModCleanup.cs
deleted file mode 100644
index a2e7dec0..00000000
--- a/Penumbra/Mods/Editor/ModCleanup.cs
+++ /dev/null
@@ -1,629 +0,0 @@
-namespace Penumbra.Mods;
-
-public partial class Mod
-{
- public partial class Manager
- {
- //public class Normalizer
- //{
- // private Dictionary< Utf8GamePath, (FullPath Path, int GroupPriority) > Files = new();
- // private Dictionary< Utf8GamePath, (FullPath Path, int GroupPriority) > Swaps = new();
- // private HashSet< (MetaManipulation Manipulation, int GroupPriority) > Manips = new();
- //
- // public Normalizer( Mod mod )
- // {
- // // Default changes are irrelevant since they can only be overwritten.
- // foreach( var group in mod.Groups )
- // {
- // foreach( var option in group )
- // {
- // foreach( var (key, value) in option.Files )
- // {
- // if( !Files.TryGetValue( key, out var list ) )
- // {
- // list = new List< (FullPath Path, IModGroup Group, ISubMod Option) > { ( value, @group, option ) };
- // Files[ key ] = list;
- // }
- // else
- // {
- // list.Add( ( value, @group, option ) );
- // }
- // }
- // }
- // }
- // }
- //
- // // Normalize a mod, this entails:
- // // - If
- // public static void Normalize( Mod mod )
- // {
- // NormalizeOptions( mod );
- // MergeSingleGroups( mod );
- // DeleteEmptyGroups( mod );
- // }
- //
- //
- // // Delete every option group that has either no options,
- // // or exclusively empty options.
- // // Triggers changes through calling ModManager.
- // private static void DeleteEmptyGroups( Mod mod )
- // {
- // for( var i = 0; i < mod.Groups.Count; ++i )
- // {
- // DeleteIdenticalOptions( mod, i );
- // var group = mod.Groups[ i ];
- // if( group.Count == 0 || group.All( o => o.FileSwaps.Count == 0 && o.Files.Count == 0 && o.Manipulations.Count == 0 ) )
- // {
- // Penumbra.ModManager.DeleteModGroup( mod, i-- );
- // }
- // }
- // }
- //
- // // Merge every non-optional group into the default mod.
- // // Overwrites default mod entries if necessary.
- // // Deletes the non-optional group afterwards.
- // // Triggers changes through calling ModManager.
- // private static void MergeSingleGroup( Mod mod )
- // {
- // var defaultMod = ( SubMod )mod.Default;
- // for( var i = 0; i < mod.Groups.Count; ++i )
- // {
- // var group = mod.Groups[ i ];
- // if( group.Type == SelectType.Single && group.Count == 1 )
- // {
- // defaultMod.MergeIn( group[ 0 ] );
- //
- // Penumbra.ModManager.DeleteModGroup( mod, i-- );
- // }
- // }
- // }
- //
- // private static void NotifyChanges( Mod mod, int groupIdx, ModOptionChangeType type, ref bool anyChanges )
- // {
- // if( anyChanges )
- // {
- // for( var i = 0; i < mod.Groups[ groupIdx ].Count; ++i )
- // {
- // Penumbra.ModManager.ModOptionChanged.Invoke( type, mod, groupIdx, i, -1 );
- // }
- //
- // anyChanges = false;
- // }
- // }
- //
- // private static void NormalizeOptions( Mod mod )
- // {
- // var defaultMod = ( SubMod )mod.Default;
- //
- // for( var i = 0; i < mod.Groups.Count; ++i )
- // {
- // var group = mod.Groups[ i ];
- // if( group.Type == SelectType.Multi || group.Count < 2 )
- // {
- // continue;
- // }
- //
- // var firstOption = mod.Groups[ i ][ 0 ];
- // var anyChanges = false;
- // foreach( var (key, value) in firstOption.Files.ToList() )
- // {
- // if( group.Skip( 1 ).All( o => o.Files.TryGetValue( key, out var v ) && v.Equals( value ) ) )
- // {
- // anyChanges = true;
- // defaultMod.FileData[ key ] = value;
- // foreach( var option in group.Cast< SubMod >() )
- // {
- // option.FileData.Remove( key );
- // }
- // }
- // }
- //
- // NotifyChanges( mod, i, ModOptionChangeType.OptionFilesChanged, ref anyChanges );
- //
- // foreach( var (key, value) in firstOption.FileSwaps.ToList() )
- // {
- // if( group.Skip( 1 ).All( o => o.FileSwaps.TryGetValue( key, out var v ) && v.Equals( value ) ) )
- // {
- // anyChanges = true;
- // defaultMod.FileData[ key ] = value;
- // foreach( var option in group.Cast< SubMod >() )
- // {
- // option.FileSwapData.Remove( key );
- // }
- // }
- // }
- //
- // NotifyChanges( mod, i, ModOptionChangeType.OptionSwapsChanged, ref anyChanges );
- //
- // anyChanges = false;
- // foreach( var manip in firstOption.Manipulations.ToList() )
- // {
- // if( group.Skip( 1 ).All( o => ( ( HashSet< MetaManipulation > )o.Manipulations ).TryGetValue( manip, out var m )
- // && manip.EntryEquals( m ) ) )
- // {
- // anyChanges = true;
- // defaultMod.ManipulationData.Remove( manip );
- // defaultMod.ManipulationData.Add( manip );
- // foreach( var option in group.Cast< SubMod >() )
- // {
- // option.ManipulationData.Remove( manip );
- // }
- // }
- // }
- //
- // NotifyChanges( mod, i, ModOptionChangeType.OptionMetaChanged, ref anyChanges );
- // }
- // }
- //
- //
- // // Delete all options that are entirely identical.
- // // Deletes the later occurring option.
- // private static void DeleteIdenticalOptions( Mod mod, int groupIdx )
- // {
- // var group = mod.Groups[ groupIdx ];
- // for( var i = 0; i < group.Count; ++i )
- // {
- // var option = group[ i ];
- // for( var j = i + 1; j < group.Count; ++j )
- // {
- // var option2 = group[ j ];
- // if( option.Files.SetEquals( option2.Files )
- // && option.FileSwaps.SetEquals( option2.FileSwaps )
- // && option.Manipulations.SetEquals( option2.Manipulations ) )
- // {
- // Penumbra.ModManager.DeleteOption( mod, groupIdx, j-- );
- // }
- // }
- // }
- // }
- //}
- }
-}
-
-// TODO Everything
-//ublic class ModCleanup
-//
-// private const string Duplicates = "Duplicates";
-// private const string Required = "Required";
-//
-// private readonly DirectoryInfo _baseDir;
-// private readonly ModMeta _mod;
-
-//
-// private readonly Dictionary< long, List< FileInfo > > _filesBySize = new();
-//
-//
-// private ModCleanup( DirectoryInfo baseDir, ModMeta mod )
-// {
-// _baseDir = baseDir;
-// _mod = mod;
-// BuildDict();
-// }
-//
-// private void BuildDict()
-// {
-// foreach( var file in _baseDir.EnumerateFiles( "*.*", SearchOption.AllDirectories ) )
-// {
-// var fileLength = file.Length;
-// if( _filesBySize.TryGetValue( fileLength, out var files ) )
-// {
-// files.Add( file );
-// }
-// else
-// {
-// _filesBySize[ fileLength ] = new List< FileInfo > { file };
-// }
-// }
-// }
-//
-// private static DirectoryInfo CreateNewModDir( Mod mod, string optionGroup, string option )
-// {
-// var newName = $"{mod.BasePath.Name}_{optionGroup}_{option}";
-// return TexToolsImport.CreateModFolder( new DirectoryInfo( Penumbra.Config.ModDirectory ), newName );
-// }
-//
-// private static Mod CreateNewMod( DirectoryInfo newDir, string newSortOrder )
-// {
-// var idx = Penumbra.ModManager.AddMod( newDir );
-// var newMod = Penumbra.ModManager.Mods[ idx ];
-// newMod.Move( newSortOrder );
-// newMod.ComputeChangedItems();
-// ModFileSystem.InvokeChange();
-// return newMod;
-// }
-//
-// private static ModMeta CreateNewMeta( DirectoryInfo newDir, Mod mod, string name, string optionGroup, string option )
-// {
-// var newMeta = new ModMeta
-// {
-// Author = mod.Meta.Author,
-// Name = name,
-// Description = $"Split from {mod.Meta.Name} Group {optionGroup} Option {option}.",
-// };
-// var metaFile = new FileInfo( Path.Combine( newDir.FullName, "meta.json" ) );
-// newMeta.SaveToFile( metaFile );
-// return newMeta;
-// }
-//
-// private static void CreateModSplit( HashSet< string > unseenPaths, Mod mod, OptionGroup group, Option option )
-// {
-// try
-// {
-// var newDir = CreateNewModDir( mod, group.GroupName, option.OptionName );
-// var newName = group.SelectionType == SelectType.Multi ? $"{group.GroupName} - {option.OptionName}" : option.OptionName;
-// var newMeta = CreateNewMeta( newDir, mod, newName, group.GroupName, option.OptionName );
-// foreach( var (fileName, paths) in option.OptionFiles )
-// {
-// var oldPath = Path.Combine( mod.BasePath.FullName, fileName.ToString() );
-// unseenPaths.Remove( oldPath );
-// if( File.Exists( oldPath ) )
-// {
-// foreach( var path in paths )
-// {
-// var newPath = Path.Combine( newDir.FullName, path.ToString() );
-// Directory.CreateDirectory( Path.GetDirectoryName( newPath )! );
-// File.Copy( oldPath, newPath, true );
-// }
-// }
-// }
-//
-// var newSortOrder = group.SelectionType == SelectType.Single
-// ? $"{mod.Order.ParentFolder.FullName}/{mod.Meta.Name}/{group.GroupName}/{option.OptionName}"
-// : $"{mod.Order.ParentFolder.FullName}/{mod.Meta.Name}/{group.GroupName} - {option.OptionName}";
-// CreateNewMod( newDir, newSortOrder );
-// }
-// catch( Exception e )
-// {
-// Penumbra.Log.Error( $"Could not split Mod:\n{e}" );
-// }
-// }
-//
-// public static void SplitMod( Mod mod )
-// {
-// if( mod.Meta.Groups.Count == 0 )
-// {
-// return;
-// }
-//
-// var unseenPaths = mod.Resources.ModFiles.Select( f => f.FullName ).ToHashSet();
-// foreach( var group in mod.Meta.Groups.Values )
-// {
-// foreach( var option in group.Options )
-// {
-// CreateModSplit( unseenPaths, mod, group, option );
-// }
-// }
-//
-// if( unseenPaths.Count == 0 )
-// {
-// return;
-// }
-//
-// var defaultGroup = new OptionGroup()
-// {
-// GroupName = "Default",
-// SelectionType = SelectType.Multi,
-// };
-// var defaultOption = new Option()
-// {
-// OptionName = "Files",
-// OptionFiles = unseenPaths.ToDictionary(
-// p => Utf8RelPath.FromFile( new FileInfo( p ), mod.BasePath, out var rel ) ? rel : Utf8RelPath.Empty,
-// p => new HashSet< Utf8GamePath >()
-// { Utf8GamePath.FromFile( new FileInfo( p ), mod.BasePath, out var game, true ) ? game : Utf8GamePath.Empty } ),
-// };
-// CreateModSplit( unseenPaths, mod, defaultGroup, defaultOption );
-// }
-//
-// private static Option FindOrCreateDuplicates( ModMeta meta )
-// {
-// static Option RequiredOption()
-// => new()
-// {
-// OptionName = Required,
-// OptionDesc = "",
-// OptionFiles = new Dictionary< Utf8RelPath, HashSet< Utf8GamePath > >(),
-// };
-//
-// if( meta.Groups.TryGetValue( Duplicates, out var duplicates ) )
-// {
-// var idx = duplicates.Options.FindIndex( o => o.OptionName == Required );
-// if( idx >= 0 )
-// {
-// return duplicates.Options[ idx ];
-// }
-//
-// duplicates.Options.Add( RequiredOption() );
-// return duplicates.Options.Last();
-// }
-//
-// meta.Groups.Add( Duplicates, new OptionGroup
-// {
-// GroupName = Duplicates,
-// SelectionType = SelectType.Single,
-// Options = new List< Option > { RequiredOption() },
-// } );
-//
-// return meta.Groups[ Duplicates ].Options.First();
-// }
-//
-//
-// private void ReplaceFile( FileInfo f1, FileInfo f2 )
-// {
-// if( !Utf8RelPath.FromFile( f1, _baseDir, out var relName1 )
-// || !Utf8RelPath.FromFile( f2, _baseDir, out var relName2 ) )
-// {
-// return;
-// }
-//
-// var inOption1 = false;
-// var inOption2 = false;
-// foreach( var option in _mod.Groups.SelectMany( g => g.Value.Options ) )
-// {
-// if( option.OptionFiles.ContainsKey( relName1 ) )
-// {
-// inOption1 = true;
-// }
-//
-// if( !option.OptionFiles.TryGetValue( relName2, out var values ) )
-// {
-// continue;
-// }
-//
-// inOption2 = true;
-//
-// foreach( var value in values )
-// {
-// option.AddFile( relName1, value );
-// }
-//
-// option.OptionFiles.Remove( relName2 );
-// }
-//
-// if( !inOption1 || !inOption2 )
-// {
-// var duplicates = FindOrCreateDuplicates( _mod );
-// if( !inOption1 )
-// {
-// duplicates.AddFile( relName1, relName2.ToGamePath() );
-// }
-//
-// if( !inOption2 )
-// {
-// duplicates.AddFile( relName1, relName1.ToGamePath() );
-// }
-// }
-//
-// Penumbra.Log.Information( $"File {relName1} and {relName2} are identical. Deleting the second." );
-// f2.Delete();
-// }
-//
-//
-
-//
-// private static bool FileIsInAnyGroup( ModMeta meta, Utf8RelPath relPath, bool exceptDuplicates = false )
-// {
-// var groupEnumerator = exceptDuplicates
-// ? meta.Groups.Values.Where( g => g.GroupName != Duplicates )
-// : meta.Groups.Values;
-// return groupEnumerator.SelectMany( group => group.Options )
-// .Any( option => option.OptionFiles.ContainsKey( relPath ) );
-// }
-//
-// private static void CleanUpDuplicates( ModMeta meta )
-// {
-// if( !meta.Groups.TryGetValue( Duplicates, out var info ) )
-// {
-// return;
-// }
-//
-// var requiredIdx = info.Options.FindIndex( o => o.OptionName == Required );
-// if( requiredIdx >= 0 )
-// {
-// var required = info.Options[ requiredIdx ];
-// foreach( var (key, value) in required.OptionFiles.ToArray() )
-// {
-// if( value.Count > 1 || FileIsInAnyGroup( meta, key, true ) )
-// {
-// continue;
-// }
-//
-// if( value.Count == 0 || value.First().CompareTo( key.ToGamePath() ) == 0 )
-// {
-// required.OptionFiles.Remove( key );
-// }
-// }
-//
-// if( required.OptionFiles.Count == 0 )
-// {
-// info.Options.RemoveAt( requiredIdx );
-// }
-// }
-//
-// if( info.Options.Count == 0 )
-// {
-// meta.Groups.Remove( Duplicates );
-// }
-// }
-//
-// public enum GroupType
-// {
-// Both = 0,
-// Single = 1,
-// Multi = 2,
-// };
-//
-// private static void RemoveFromGroups( ModMeta meta, Utf8RelPath relPath, Utf8GamePath gamePath, GroupType type = GroupType.Both,
-// bool skipDuplicates = true )
-// {
-// if( meta.Groups.Count == 0 )
-// {
-// return;
-// }
-//
-// var enumerator = type switch
-// {
-// GroupType.Both => meta.Groups.Values,
-// GroupType.Single => meta.Groups.Values.Where( g => g.SelectionType == SelectType.Single ),
-// GroupType.Multi => meta.Groups.Values.Where( g => g.SelectionType == SelectType.Multi ),
-// _ => throw new InvalidEnumArgumentException( "Invalid Enum in RemoveFromGroups" ),
-// };
-// foreach( var group in enumerator )
-// {
-// var optionEnum = skipDuplicates
-// ? group.Options.Where( o => group.GroupName != Duplicates || o.OptionName != Required )
-// : group.Options;
-// foreach( var option in optionEnum )
-// {
-// if( option.OptionFiles.TryGetValue( relPath, out var gamePaths ) && gamePaths.Remove( gamePath ) && gamePaths.Count == 0 )
-// {
-// option.OptionFiles.Remove( relPath );
-// }
-// }
-// }
-// }
-//
-// public static bool MoveFile( ModMeta meta, string basePath, Utf8RelPath oldRelPath, Utf8RelPath newRelPath )
-// {
-// if( oldRelPath.Equals( newRelPath ) )
-// {
-// return true;
-// }
-//
-// try
-// {
-// var newFullPath = Path.Combine( basePath, newRelPath.ToString() );
-// new FileInfo( newFullPath ).Directory!.Create();
-// File.Move( Path.Combine( basePath, oldRelPath.ToString() ), newFullPath );
-// }
-// catch( Exception e )
-// {
-// Penumbra.Log.Error( $"Could not move file from {oldRelPath} to {newRelPath}:\n{e}" );
-// return false;
-// }
-//
-// foreach( var option in meta.Groups.Values.SelectMany( group => group.Options ) )
-// {
-// if( option.OptionFiles.TryGetValue( oldRelPath, out var gamePaths ) )
-// {
-// option.OptionFiles.Add( newRelPath, gamePaths );
-// option.OptionFiles.Remove( oldRelPath );
-// }
-// }
-//
-// return true;
-// }
-//
-//
-// private static void RemoveUselessGroups( ModMeta meta )
-// {
-// meta.Groups = meta.Groups.Where( kvp => kvp.Value.Options.Any( o => o.OptionFiles.Count > 0 ) )
-// .ToDictionary( kvp => kvp.Key, kvp => kvp.Value );
-// }
-//
-// // Goes through all Single-Select options and checks if file links are in each of them.
-// // If they are, it moves those files to the root folder and removes them from the groups (and puts them to duplicates, if necessary).
-// public static void Normalize( DirectoryInfo baseDir, ModMeta meta )
-// {
-// foreach( var group in meta.Groups.Values.Where( g => g.SelectionType == SelectType.Single && g.GroupName != Duplicates ) )
-// {
-// var firstOption = true;
-// HashSet< (Utf8RelPath, Utf8GamePath) > groupList = new();
-// foreach( var option in group.Options )
-// {
-// HashSet< (Utf8RelPath, Utf8GamePath) > optionList = new();
-// foreach( var (file, gamePaths) in option.OptionFiles.Select( p => ( p.Key, p.Value ) ) )
-// {
-// optionList.UnionWith( gamePaths.Select( p => ( file, p ) ) );
-// }
-//
-// if( firstOption )
-// {
-// groupList = optionList;
-// }
-// else
-// {
-// groupList.IntersectWith( optionList );
-// }
-//
-// firstOption = false;
-// }
-//
-// var newPath = new Dictionary< Utf8RelPath, Utf8GamePath >();
-// foreach( var (path, gamePath) in groupList )
-// {
-// var relPath = new Utf8RelPath( gamePath );
-// if( newPath.TryGetValue( path, out var usedGamePath ) )
-// {
-// var required = FindOrCreateDuplicates( meta );
-// var usedRelPath = new Utf8RelPath( usedGamePath );
-// required.AddFile( usedRelPath, gamePath );
-// required.AddFile( usedRelPath, usedGamePath );
-// RemoveFromGroups( meta, relPath, gamePath, GroupType.Single );
-// }
-// else if( MoveFile( meta, baseDir.FullName, path, relPath ) )
-// {
-// newPath[ path ] = gamePath;
-// if( FileIsInAnyGroup( meta, relPath ) )
-// {
-// FindOrCreateDuplicates( meta ).AddFile( relPath, gamePath );
-// }
-//
-// RemoveFromGroups( meta, relPath, gamePath, GroupType.Single );
-// }
-// }
-// }
-//
-// RemoveUselessGroups( meta );
-// ClearEmptySubDirectories( baseDir );
-// }
-//
-// public static void AutoGenerateGroups( DirectoryInfo baseDir, ModMeta meta )
-// {
-// meta.Groups.Clear();
-// ClearEmptySubDirectories( baseDir );
-// foreach( var groupDir in baseDir.EnumerateDirectories() )
-// {
-// var group = new OptionGroup
-// {
-// GroupName = groupDir.Name,
-// SelectionType = SelectType.Single,
-// Options = new List< Option >(),
-// };
-//
-// foreach( var optionDir in groupDir.EnumerateDirectories() )
-// {
-// var option = new Option
-// {
-// OptionDesc = string.Empty,
-// OptionName = optionDir.Name,
-// OptionFiles = new Dictionary< Utf8RelPath, HashSet< Utf8GamePath > >(),
-// };
-// foreach( var file in optionDir.EnumerateFiles( "*.*", SearchOption.AllDirectories ) )
-// {
-// if( Utf8RelPath.FromFile( file, baseDir, out var rel )
-// && Utf8GamePath.FromFile( file, optionDir, out var game ) )
-// {
-// option.OptionFiles[ rel ] = new HashSet< Utf8GamePath > { game };
-// }
-// }
-//
-// if( option.OptionFiles.Count > 0 )
-// {
-// group.Options.Add( option );
-// }
-// }
-//
-// if( group.Options.Count > 0 )
-// {
-// meta.Groups.Add( groupDir.Name, group );
-// }
-// }
-//
-// var idx = Penumbra.ModManager.Mods.IndexOf( m => m.Meta == meta );
-// foreach( var collection in Penumbra.CollectionManager )
-// {
-// collection.Settings[ idx ]?.FixInvalidSettings( meta );
-// }
-// }
-//
\ No newline at end of file
diff --git a/Penumbra/Mods/Mod.Creation.cs b/Penumbra/Mods/Mod.Creation.cs
index 5c7cd701..afb59c94 100644
--- a/Penumbra/Mods/Mod.Creation.cs
+++ b/Penumbra/Mods/Mod.Creation.cs
@@ -13,10 +13,17 @@ namespace Penumbra.Mods;
public partial class Mod
{
- // Create and return a new directory based on the given directory and name, that is
- // - Not Empty
- // - Unique, by appending (digit) for duplicates.
- // - Containing no symbols invalid for FFXIV or windows paths.
+ ///
+ /// Create and return a new directory based on the given directory and name, that is
+ /// - Not Empty.
+ /// - Unique, by appending (digit) for duplicates.
+ /// - Containing no symbols invalid for FFXIV or windows paths.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
internal static DirectoryInfo CreateModFolder( DirectoryInfo outDirectory, string modListName, bool create = true )
{
var name = modListName;
@@ -160,8 +167,9 @@ public partial class Mod
{
return replacement + replacement;
}
+
StringBuilder sb = new(s.Length);
- foreach( var c in s.Normalize(NormalizationForm.FormKC) )
+ foreach( var c in s.Normalize( NormalizationForm.FormKC ) )
{
if( c.IsInvalidAscii() || c.IsInvalidInPath() )
{
diff --git a/Penumbra/UI/Classes/ModEditWindow.cs b/Penumbra/UI/Classes/ModEditWindow.cs
index bbe21025..f33127b3 100644
--- a/Penumbra/UI/Classes/ModEditWindow.cs
+++ b/Penumbra/UI/Classes/ModEditWindow.cs
@@ -20,10 +20,11 @@ namespace Penumbra.UI.Classes;
public partial class ModEditWindow : Window, IDisposable
{
- private const string WindowBaseLabel = "###SubModEdit";
- private Editor? _editor;
- private Mod? _mod;
- private Vector2 _iconSize = Vector2.Zero;
+ private const string WindowBaseLabel = "###SubModEdit";
+ private Editor? _editor;
+ private Mod? _mod;
+ private Vector2 _iconSize = Vector2.Zero;
+ private bool _allowReduplicate = false;
public void ChangeMod( Mod mod )
{
@@ -118,6 +119,7 @@ public partial class ModEditWindow : Window, IDisposable
sb.Append( $" | {swaps} Swaps" );
}
+ _allowReduplicate = redirections != _editor.AvailableFiles.Count || _editor.MissingFiles.Count > 0;
sb.Append( WindowBaseLabel );
WindowName = sb.ToString();
}
@@ -288,6 +290,15 @@ public partial class ModEditWindow : Window, IDisposable
_editor.StartDuplicateCheck();
}
+ const string desc = "Tries to create a unique copy of a file for every game path manipulated and put them in [Groupname]/[Optionname]/[GamePath] order.\n"
+ + "This will also delete all unused files and directories if it succeeds.\n"
+ + "Care was taken that a failure should not destroy the mod but revert to its original state, but you use this at your own risk anyway.";
+ if( ImGuiUtil.DrawDisabledButton( "Re-Duplicate and Normalize Mod", Vector2.Zero, desc, !_allowReduplicate ) )
+ {
+ _mod!.Normalize( Penumbra.ModManager );
+ _editor.RevertFiles();
+ }
+
if( !_editor.DuplicatesFinished )
{
ImGui.SameLine();