Some mod movement.

This commit is contained in:
Ottermandias 2023-04-01 14:24:12 +02:00
parent c12dbf3f8a
commit 577669b21f
26 changed files with 726 additions and 732 deletions

View file

@ -828,7 +828,7 @@ public class PenumbraApi : IDisposable, IPenumbraApi
public PenumbraApiEc CreateNamedTemporaryCollection(string name)
{
CheckInitialized();
if (name.Length == 0 || Mod.Creator.ReplaceBadXivSymbols(name) != name)
if (name.Length == 0 || ModCreator.ReplaceBadXivSymbols(name) != name)
return PenumbraApiEc.InvalidArgument;
return _tempCollections.CreateTemporaryCollection(name).Length > 0

View file

@ -11,6 +11,7 @@ using OtterGui.Widgets;
using Penumbra.GameData.Enums;
using Penumbra.Import.Structs;
using Penumbra.Mods;
using Penumbra.Mods.Manager;
using Penumbra.Services;
using Penumbra.UI;
using Penumbra.UI.Classes;

View file

@ -45,7 +45,7 @@ public partial class TexToolsImporter
};
Penumbra.Log.Information( $" -> Importing {archive.Type} Archive." );
_currentModDirectory = Mod.Creator.CreateModFolder( _baseDirectory, Path.GetRandomFileName() );
_currentModDirectory = ModCreator.CreateModFolder( _baseDirectory, Path.GetRandomFileName() );
var options = new ExtractionOptions()
{
ExtractFullPath = true,
@ -100,18 +100,18 @@ public partial class TexToolsImporter
// Use either the top-level directory as the mods base name, or the (fixed for path) name in the json.
if( leadDir )
{
_currentModDirectory = Mod.Creator.CreateModFolder( _baseDirectory, baseName, false );
_currentModDirectory = ModCreator.CreateModFolder( _baseDirectory, baseName, false );
Directory.Move( Path.Combine( oldName, baseName ), _currentModDirectory.FullName );
Directory.Delete( oldName );
}
else
{
_currentModDirectory = Mod.Creator.CreateModFolder( _baseDirectory, name, false );
_currentModDirectory = ModCreator.CreateModFolder( _baseDirectory, name, false );
Directory.Move( oldName, _currentModDirectory.FullName );
}
_currentModDirectory.Refresh();
Mod.Creator.SplitMultiGroups( _currentModDirectory );
ModCreator.SplitMultiGroups( _currentModDirectory );
return _currentModDirectory;
}

View file

@ -33,14 +33,14 @@ public partial class TexToolsImporter
var modList = modListRaw.Select( m => JsonConvert.DeserializeObject< SimpleMod >( m, JsonSettings )! ).ToList();
_currentModDirectory = Mod.Creator.CreateModFolder( _baseDirectory, Path.GetFileNameWithoutExtension( modPackFile.Name ) );
_currentModDirectory = ModCreator.CreateModFolder( _baseDirectory, Path.GetFileNameWithoutExtension( modPackFile.Name ) );
// Create a new ModMeta from the TTMP mod list info
_modManager.DataEditor.CreateMeta( _currentModDirectory, _currentModName, DefaultTexToolsData.Author, DefaultTexToolsData.Description, null, null );
// Open the mod data file from the mod pack as a SqPackStream
_streamDisposer = GetSqPackStreamStream( extractedModPack, "TTMPD.mpd" );
ExtractSimpleModList( _currentModDirectory, modList );
Mod.Creator.CreateDefaultFiles( _currentModDirectory );
ModCreator.CreateDefaultFiles( _currentModDirectory );
ResetStreamDisposer();
return _currentModDirectory;
}
@ -89,7 +89,7 @@ public partial class TexToolsImporter
_currentOptionName = DefaultTexToolsData.DefaultOption;
Penumbra.Log.Information( " -> Importing Simple V2 ModPack" );
_currentModDirectory = Mod.Creator.CreateModFolder( _baseDirectory, _currentModName );
_currentModDirectory = ModCreator.CreateModFolder( _baseDirectory, _currentModName );
_modManager.DataEditor.CreateMeta( _currentModDirectory, _currentModName, modList.Author, string.IsNullOrEmpty( modList.Description )
? "Mod imported from TexTools mod pack"
: modList.Description, modList.Version, modList.Url );
@ -97,7 +97,7 @@ public partial class TexToolsImporter
// Open the mod data file from the mod pack as a SqPackStream
_streamDisposer = GetSqPackStreamStream( extractedModPack, "TTMPD.mpd" );
ExtractSimpleModList( _currentModDirectory, modList.SimpleModsList );
Mod.Creator.CreateDefaultFiles( _currentModDirectory );
ModCreator.CreateDefaultFiles( _currentModDirectory );
ResetStreamDisposer();
return _currentModDirectory;
}
@ -134,7 +134,7 @@ public partial class TexToolsImporter
_currentNumOptions = GetOptionCount( modList );
_currentModName = modList.Name;
_currentModDirectory = Mod.Creator.CreateModFolder( _baseDirectory, _currentModName );
_currentModDirectory = ModCreator.CreateModFolder( _baseDirectory, _currentModName );
_modManager.DataEditor.CreateMeta( _currentModDirectory, _currentModName, modList.Author, modList.Description, modList.Version, modList.Url );
if( _currentNumOptions == 0 )
@ -172,7 +172,7 @@ public partial class TexToolsImporter
{
var name = numGroups == 1 ? _currentGroupName : $"{_currentGroupName}, Part {groupId + 1}";
options.Clear();
var groupFolder = Mod.Creator.NewSubFolderName( _currentModDirectory, name )
var groupFolder = ModCreator.NewSubFolderName( _currentModDirectory, name )
?? new DirectoryInfo( Path.Combine( _currentModDirectory.FullName,
numGroups == 1 ? $"Group {groupPriority + 1}" : $"Group {groupPriority + 1}, Part {groupId + 1}" ) );
@ -182,10 +182,10 @@ public partial class TexToolsImporter
var option = allOptions[ i + optionIdx ];
_token.ThrowIfCancellationRequested();
_currentOptionName = option.Name;
var optionFolder = Mod.Creator.NewSubFolderName( groupFolder, option.Name )
var optionFolder = ModCreator.NewSubFolderName( groupFolder, option.Name )
?? new DirectoryInfo( Path.Combine( groupFolder.FullName, $"Option {i + optionIdx + 1}" ) );
ExtractSimpleModList( optionFolder, option.ModsJsons );
options.Add( Mod.Creator.CreateSubMod( _currentModDirectory, optionFolder, option ) );
options.Add( ModCreator.CreateSubMod( _currentModDirectory, optionFolder, option ) );
if( option.IsChecked )
{
defaultSettings = group.SelectionType == GroupType.Multi
@ -206,12 +206,12 @@ public partial class TexToolsImporter
if( empty != null )
{
_currentOptionName = empty.Name;
options.Insert( 0, Mod.Creator.CreateEmptySubMod( empty.Name ) );
options.Insert( 0, ModCreator.CreateEmptySubMod( empty.Name ) );
defaultSettings = defaultSettings == null ? 0 : defaultSettings.Value + 1;
}
}
Mod.Creator.CreateOptionGroup( _currentModDirectory, group.SelectionType, name, groupPriority, groupPriority,
ModCreator.CreateOptionGroup( _currentModDirectory, group.SelectionType, name, groupPriority, groupPriority,
defaultSettings ?? 0, group.Description, options );
++groupPriority;
}
@ -219,7 +219,7 @@ public partial class TexToolsImporter
}
ResetStreamDisposer();
Mod.Creator.CreateDefaultFiles( _currentModDirectory );
ModCreator.CreateDefaultFiles( _currentModDirectory );
return _currentModDirectory;
}

View file

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

View file

@ -181,10 +181,10 @@ public class ModNormalizer
for (var i = _redirections[groupIdx + 1].Count; i < group.Count; ++i)
_redirections[groupIdx + 1].Add(new Dictionary<Utf8GamePath, FullPath>());
var groupDir = Mod.Creator.CreateModFolder(directory, group.Name);
var groupDir = ModCreator.CreateModFolder(directory, group.Name);
foreach (var option in group.OfType<SubMod>())
{
var optionDir = Mod.Creator.CreateModFolder(groupDir, option.Name);
var optionDir = ModCreator.CreateModFolder(groupDir, option.Name);
newDict = _redirections[groupIdx + 1][option.OptionIdx];
newDict.Clear();

View file

@ -10,25 +10,25 @@ using Penumbra.GameData.Enums;
using Penumbra.Meta.Manipulations;
using Penumbra.Services;
namespace Penumbra.Mods;
namespace Penumbra.Mods.Manager;
public class ModCacheManager : IDisposable, IReadOnlyList<ModCache>
{
private readonly CommunicatorService _communicator;
private readonly IdentifierService _identifier;
private readonly IReadOnlyList<Mod> _modManager;
private readonly IdentifierService _identifier;
private readonly IReadOnlyList<Mod> _modManager;
private readonly List<ModCache> _cache = new();
public ModCacheManager(CommunicatorService communicator, IdentifierService identifier, ModManager modManager)
{
_communicator = communicator;
_identifier = identifier;
_modManager = modManager;
_identifier = identifier;
_modManager = modManager;
_communicator.ModOptionChanged.Event += OnModOptionChange;
_communicator.ModPathChanged.Event += OnModPathChange;
_communicator.ModDataChanged.Event += OnModDataChange;
_communicator.ModOptionChanged.Event += OnModOptionChange;
_communicator.ModPathChanged.Event += OnModPathChange;
_communicator.ModDataChanged.Event += OnModDataChange;
_communicator.ModDiscoveryFinished.Event += OnModDiscoveryFinished;
if (!identifier.Valid)
identifier.FinishedCreation += OnIdentifierCreation;
@ -51,9 +51,9 @@ public class ModCacheManager : IDisposable, IReadOnlyList<ModCache>
public void Dispose()
{
_communicator.ModOptionChanged.Event -= OnModOptionChange;
_communicator.ModPathChanged.Event -= OnModPathChange;
_communicator.ModDataChanged.Event -= OnModDataChange;
_communicator.ModOptionChanged.Event -= OnModOptionChange;
_communicator.ModPathChanged.Event -= OnModPathChange;
_communicator.ModDataChanged.Event -= OnModDataChange;
_communicator.ModDiscoveryFinished.Event -= OnModDiscoveryFinished;
}
@ -232,17 +232,17 @@ public class ModCacheManager : IDisposable, IReadOnlyList<ModCache>
private static void UpdateCounts(ModCache cache, Mod mod)
{
cache.TotalFileCount = mod.Default.Files.Count;
cache.TotalSwapCount = mod.Default.FileSwaps.Count;
cache.TotalFileCount = mod.Default.Files.Count;
cache.TotalSwapCount = mod.Default.FileSwaps.Count;
cache.TotalManipulations = mod.Default.Manipulations.Count;
cache.HasOptions = false;
cache.HasOptions = false;
foreach (var group in mod.Groups)
{
cache.HasOptions |= group.IsOption;
foreach (var s in group)
{
cache.TotalFileCount += s.Files.Count;
cache.TotalSwapCount += s.FileSwaps.Count;
cache.TotalFileCount += s.Files.Count;
cache.TotalSwapCount += s.FileSwaps.Count;
cache.TotalManipulations += s.Manipulations.Count;
}
}

View file

@ -7,7 +7,7 @@ using OtterGui.Classes;
using Penumbra.Services;
using Penumbra.Util;
namespace Penumbra.Mods;
namespace Penumbra.Mods.Manager;
[Flags]
public enum ModDataChangeType : ushort
@ -25,27 +25,25 @@ public enum ModDataChangeType : ushort
Favorite = 0x0200,
LocalTags = 0x0400,
Note = 0x0800,
}
}
public class ModDataEditor
{
private readonly FilenameService _filenameService;
private readonly SaveService _saveService;
private readonly CommunicatorService _communicatorService;
public ModDataEditor(FilenameService filenameService, SaveService saveService, CommunicatorService communicatorService)
public ModDataEditor(SaveService saveService, CommunicatorService communicatorService)
{
_filenameService = filenameService;
_saveService = saveService;
_communicatorService = communicatorService;
}
public string MetaFile(Mod mod)
=> _filenameService.ModMetaPath(mod);
=> _saveService.FileNames.ModMetaPath(mod);
public string DataFile(Mod mod)
=> _filenameService.LocalDataFile(mod);
=> _saveService.FileNames.LocalDataFile(mod);
/// <summary> Create the file containing the meta information about a mod from scratch. </summary>
public void CreateMeta(DirectoryInfo directory, string? name, string? author, string? description, string? version,
string? website)
@ -56,12 +54,12 @@ public class ModDataEditor
mod.Description = description ?? mod.Description;
mod.Version = version ?? mod.Version;
mod.Website = website ?? mod.Website;
_saveService.ImmediateSave(new Mod.ModMeta(mod));
_saveService.ImmediateSave(new ModMeta(mod));
}
public ModDataChangeType LoadLocalData(Mod mod)
{
var dataFile = _filenameService.LocalDataFile(mod);
var dataFile = _saveService.FileNames.LocalDataFile(mod);
var importDate = 0L;
var localTags = Enumerable.Empty<string>();
@ -98,7 +96,7 @@ public class ModDataEditor
changes |= ModDataChangeType.ImportDate;
}
changes |= mod.UpdateTags(null, localTags);
changes |= ModLocalData.UpdateTags(mod, null, localTags);
if (mod.Favorite != favorite)
{
@ -113,14 +111,14 @@ public class ModDataEditor
}
if (save)
_saveService.QueueSave(new Mod.ModData(mod));
_saveService.QueueSave(new ModLocalData(mod));
return changes;
}
public ModDataChangeType LoadMeta(Mod mod)
{
var metaFile = _filenameService.ModMetaPath(mod);
var metaFile = _saveService.FileNames.ModMetaPath(mod);
if (!File.Exists(metaFile))
{
Penumbra.Log.Debug($"No mod meta found for {mod.ModPath.Name}.");
@ -137,7 +135,7 @@ public class ModDataEditor
var newDescription = json[nameof(Mod.Description)]?.Value<string>() ?? string.Empty;
var newVersion = json[nameof(Mod.Version)]?.Value<string>() ?? string.Empty;
var newWebsite = json[nameof(Mod.Website)]?.Value<string>() ?? string.Empty;
var newFileVersion = json[nameof(Mod.ModMeta.FileVersion)]?.Value<uint>() ?? 0;
var newFileVersion = json[nameof(ModMeta.FileVersion)]?.Value<uint>() ?? 0;
var importDate = json[nameof(Mod.ImportDate)]?.Value<long>();
var modTags = json[nameof(Mod.ModTags)]?.Values<string>().OfType<string>();
@ -172,14 +170,12 @@ public class ModDataEditor
mod.Website = newWebsite;
}
if (newFileVersion != Mod.ModMeta.FileVersion)
{
if (Mod.Migration.Migrate(mod, json, ref newFileVersion))
if (newFileVersion != ModMeta.FileVersion)
if (ModMigration.Migrate(_saveService, mod, json, ref newFileVersion))
{
changes |= ModDataChangeType.Migration;
_saveService.ImmediateSave(new Mod.ModMeta(mod));
_saveService.ImmediateSave(new ModMeta(mod));
}
}
if (importDate != null && mod.ImportDate != importDate.Value)
{
@ -187,7 +183,7 @@ public class ModDataEditor
changes |= ModDataChangeType.ImportDate;
}
changes |= mod.UpdateTags(modTags, null);
changes |= ModLocalData.UpdateTags(mod, modTags, null);
return changes;
}
@ -205,7 +201,7 @@ public class ModDataEditor
var oldName = mod.Name;
mod.Name = newName;
_saveService.QueueSave(new Mod.ModMeta(mod));
_saveService.QueueSave(new ModMeta(mod));
_communicatorService.ModDataChanged.Invoke(ModDataChangeType.Name, mod, oldName.Text);
}
@ -215,7 +211,7 @@ public class ModDataEditor
return;
mod.Author = newAuthor;
_saveService.QueueSave(new Mod.ModMeta(mod));
_saveService.QueueSave(new ModMeta(mod));
_communicatorService.ModDataChanged.Invoke(ModDataChangeType.Author, mod, null);
}
@ -225,7 +221,7 @@ public class ModDataEditor
return;
mod.Description = newDescription;
_saveService.QueueSave(new Mod.ModMeta(mod));
_saveService.QueueSave(new ModMeta(mod));
_communicatorService.ModDataChanged.Invoke(ModDataChangeType.Description, mod, null);
}
@ -235,7 +231,7 @@ public class ModDataEditor
return;
mod.Version = newVersion;
_saveService.QueueSave(new Mod.ModMeta(mod));
_saveService.QueueSave(new ModMeta(mod));
_communicatorService.ModDataChanged.Invoke(ModDataChangeType.Version, mod, null);
}
@ -245,7 +241,7 @@ public class ModDataEditor
return;
mod.Website = newWebsite;
_saveService.QueueSave(new Mod.ModMeta(mod));
_saveService.QueueSave(new ModMeta(mod));
_communicatorService.ModDataChanged.Invoke(ModDataChangeType.Website, mod, null);
}
@ -261,7 +257,7 @@ public class ModDataEditor
return;
mod.Favorite = state;
_saveService.QueueSave(new Mod.ModData(mod));
_saveService.QueueSave(new ModLocalData(mod));
;
_communicatorService.ModDataChanged.Invoke(ModDataChangeType.Favorite, mod, null);
}
@ -272,7 +268,7 @@ public class ModDataEditor
return;
mod.Note = newNote;
_saveService.QueueSave(new Mod.ModData(mod));
_saveService.QueueSave(new ModLocalData(mod));
;
_communicatorService.ModDataChanged.Invoke(ModDataChangeType.Favorite, mod, null);
}
@ -287,20 +283,20 @@ public class ModDataEditor
ModDataChangeType flags = 0;
if (tagIdx == which.Count)
{
flags = mod.UpdateTags(local ? null : which.Append(newTag), local ? which.Append(newTag) : null);
flags = ModLocalData.UpdateTags(mod, local ? null : which.Append(newTag), local ? which.Append(newTag) : null);
}
else
{
var tmp = which.ToArray();
tmp[tagIdx] = newTag;
flags = mod.UpdateTags(local ? null : tmp, local ? tmp : null);
flags = ModLocalData.UpdateTags(mod, local ? null : tmp, local ? tmp : null);
}
if (flags.HasFlag(ModDataChangeType.ModTags))
_saveService.QueueSave(new Mod.ModMeta(mod));
_saveService.QueueSave(new ModMeta(mod));
if (flags.HasFlag(ModDataChangeType.LocalTags))
_saveService.QueueSave(new Mod.ModData(mod));
_saveService.QueueSave(new ModLocalData(mod));
if (flags != 0)
_communicatorService.ModDataChanged.Invoke(flags, mod, null);
@ -308,8 +304,8 @@ public class ModDataEditor
public void MoveDataFile(DirectoryInfo oldMod, DirectoryInfo newMod)
{
var oldFile = _filenameService.LocalDataFile(oldMod.Name);
var newFile = _filenameService.LocalDataFile(newMod.Name);
var oldFile = _saveService.FileNames.LocalDataFile(oldMod.Name);
var newFile = _saveService.FileNames.LocalDataFile(newMod.Name);
if (!File.Exists(oldFile))
return;

View file

@ -5,11 +5,10 @@ using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using OtterGui.Filesystem;
using Penumbra.Mods.Manager;
using Penumbra.Services;
using Penumbra.Util;
namespace Penumbra.Mods;
namespace Penumbra.Mods.Manager;
public sealed class ModFileSystem : FileSystem<Mod>, IDisposable, ISavable
{

View file

@ -5,7 +5,7 @@ using System.Linq;
using System.Threading.Tasks;
using Penumbra.Services;
namespace Penumbra.Mods;
namespace Penumbra.Mods.Manager;
/// <summary> Describes the state of a potential move-target for a mod. </summary>
public enum NewDirectoryState
@ -35,7 +35,7 @@ public sealed class ModManager : ModStorage
_config = config;
_communicator = communicator;
DataEditor = dataEditor;
OptionEditor = optionEditor;
OptionEditor = optionEditor;
SetBaseDirectory(config.ModDirectory, true);
DiscoverMods();
}
@ -73,7 +73,7 @@ public sealed class ModManager : ModStorage
if (this.Any(m => m.ModPath.Name == modFolder.Name))
return;
Mod.Creator.SplitMultiGroups(modFolder);
ModCreator.SplitMultiGroups(modFolder);
var mod = Mod.LoadMod(this, modFolder, true);
if (mod == null)
return;
@ -206,7 +206,7 @@ public sealed class ModManager : ModStorage
if (oldName == newName)
return NewDirectoryState.Identical;
var fixedNewName = Mod.Creator.ReplaceBadXivSymbols(newName);
var fixedNewName = ModCreator.ReplaceBadXivSymbols(newName);
if (fixedNewName != newName)
return NewDirectoryState.ContainsInvalidSymbols;

View file

@ -0,0 +1,244 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OtterGui;
using Penumbra.Api.Enums;
using Penumbra.String.Classes;
using Penumbra.Util;
namespace Penumbra.Mods.Manager;
public static partial class ModMigration
{
[GeneratedRegex(@"group_\d{3}_", RegexOptions.Compiled | RegexOptions.NonBacktracking | RegexOptions.ExplicitCapture)]
private static partial Regex GroupRegex();
[GeneratedRegex("^group_", RegexOptions.Compiled)]
private static partial Regex GroupStartRegex();
public static bool Migrate(SaveService saveService, Mod mod, JObject json, ref uint fileVersion)
=> MigrateV0ToV1(saveService, mod, json, ref fileVersion) || MigrateV1ToV2(mod, ref fileVersion) || MigrateV2ToV3(mod, ref fileVersion);
private static bool MigrateV2ToV3(Mod _, ref uint fileVersion)
{
if (fileVersion > 2)
return false;
// Remove import time.
fileVersion = 3;
return true;
}
private static bool MigrateV1ToV2(Mod mod, ref uint fileVersion)
{
if (fileVersion > 1)
return false;
if (!mod.GroupFiles.All(g => GroupRegex().IsMatch(g.Name)))
foreach (var (group, index) in mod.GroupFiles.WithIndex().ToArray())
{
var newName = GroupStartRegex().Replace(group.Name, $"group_{index + 1:D3}_");
try
{
if (newName != group.Name)
group.MoveTo(Path.Combine(group.DirectoryName ?? string.Empty, newName), false);
}
catch (Exception e)
{
Penumbra.Log.Error($"Could not rename group file {group.Name} to {newName} during migration:\n{e}");
}
}
fileVersion = 2;
return true;
}
private static bool MigrateV0ToV1(SaveService saveService, Mod mod, JObject json, ref uint fileVersion)
{
if (fileVersion > 0)
return false;
var swaps = json["FileSwaps"]?.ToObject<Dictionary<Utf8GamePath, FullPath>>()
?? new Dictionary<Utf8GamePath, FullPath>();
var groups = json["Groups"]?.ToObject<Dictionary<string, OptionGroupV0>>() ?? new Dictionary<string, OptionGroupV0>();
var priority = 1;
var seenMetaFiles = new HashSet<FullPath>();
foreach (var group in groups.Values)
ConvertGroup(mod, group, ref priority, seenMetaFiles);
foreach (var unusedFile in mod.FindUnusedFiles().Where(f => !seenMetaFiles.Contains(f)))
{
if (unusedFile.ToGamePath(mod.ModPath, out var gamePath)
&& !mod._default.FileData.TryAdd(gamePath, unusedFile))
Penumbra.Log.Error($"Could not add {gamePath} because it already points to {mod._default.FileData[gamePath]}.");
}
mod._default.FileSwapData.Clear();
mod._default.FileSwapData.EnsureCapacity(swaps.Count);
foreach (var (gamePath, swapPath) in swaps)
mod._default.FileSwapData.Add(gamePath, swapPath);
mod._default.IncorporateMetaChanges(mod.ModPath, true);
foreach (var (_, index) in mod.Groups.WithIndex())
saveService.ImmediateSave(new ModSaveGroup(mod, index));
// Delete meta files.
foreach (var file in seenMetaFiles.Where(f => f.Exists))
{
try
{
File.Delete(file.FullName);
}
catch (Exception e)
{
Penumbra.Log.Warning($"Could not delete meta file {file.FullName} during migration:\n{e}");
}
}
// Delete old meta files.
var oldMetaFile = Path.Combine(mod.ModPath.FullName, "metadata_manipulations.json");
if (File.Exists(oldMetaFile))
try
{
File.Delete(oldMetaFile);
}
catch (Exception e)
{
Penumbra.Log.Warning($"Could not delete old meta file {oldMetaFile} during migration:\n{e}");
}
fileVersion = 1;
saveService.ImmediateSave(new ModSaveGroup(mod, -1));
return true;
}
private static void ConvertGroup(Mod mod, OptionGroupV0 group, ref int priority, HashSet<FullPath> seenMetaFiles)
{
if (group.Options.Count == 0)
return;
switch (group.SelectionType)
{
case GroupType.Multi:
var optionPriority = 0;
var newMultiGroup = new MultiModGroup()
{
Name = group.GroupName,
Priority = priority++,
Description = string.Empty,
};
mod._groups.Add(newMultiGroup);
foreach (var option in group.Options)
newMultiGroup.PrioritizedOptions.Add((SubModFromOption(mod, option, seenMetaFiles), optionPriority++));
break;
case GroupType.Single:
if (group.Options.Count == 1)
{
AddFilesToSubMod(mod._default, mod.ModPath, group.Options[0], seenMetaFiles);
return;
}
var newSingleGroup = new SingleModGroup()
{
Name = group.GroupName,
Priority = priority++,
Description = string.Empty,
};
mod._groups.Add(newSingleGroup);
foreach (var option in group.Options)
newSingleGroup.OptionData.Add(SubModFromOption(mod, option, seenMetaFiles));
break;
}
}
private static void AddFilesToSubMod(SubMod mod, DirectoryInfo basePath, OptionV0 option, HashSet<FullPath> seenMetaFiles)
{
foreach (var (relPath, gamePaths) in option.OptionFiles)
{
var fullPath = new FullPath(basePath, relPath);
foreach (var gamePath in gamePaths)
mod.FileData.TryAdd(gamePath, fullPath);
if (fullPath.Extension is ".meta" or ".rgsp")
seenMetaFiles.Add(fullPath);
}
}
private static SubMod SubModFromOption(Mod mod, OptionV0 option, HashSet<FullPath> seenMetaFiles)
{
var subMod = new SubMod(mod) { Name = option.OptionName };
AddFilesToSubMod(subMod, mod.ModPath, option, seenMetaFiles);
subMod.IncorporateMetaChanges(mod.ModPath, false);
return subMod;
}
private struct OptionV0
{
public string OptionName = string.Empty;
public string OptionDesc = string.Empty;
[JsonProperty(ItemConverterType = typeof(SingleOrArrayConverter<Utf8GamePath>))]
public Dictionary<Utf8RelPath, HashSet<Utf8GamePath>> OptionFiles = new();
public OptionV0()
{ }
}
private struct OptionGroupV0
{
public string GroupName = string.Empty;
[JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
public GroupType SelectionType = GroupType.Single;
public List<OptionV0> Options = new();
public OptionGroupV0()
{ }
}
// Not used anymore, but required for migration.
private class SingleOrArrayConverter<T> : JsonConverter
{
public override bool CanConvert(Type objectType)
=> objectType == typeof(HashSet<T>);
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
var token = JToken.Load(reader);
if (token.Type == JTokenType.Array)
return token.ToObject<HashSet<T>>() ?? new HashSet<T>();
var tmp = token.ToObject<T>();
return tmp != null
? new HashSet<T> { tmp }
: new HashSet<T>();
}
public override bool CanWrite
=> true;
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
writer.WriteStartArray();
if (value != null)
{
var v = (HashSet<T>)value;
foreach (var val in v)
serializer.Serialize(writer, val?.ToString());
}
writer.WriteEndArray();
}
}
}

View file

@ -13,273 +13,270 @@ using Penumbra.String.Classes;
namespace Penumbra.Mods;
public partial class Mod
internal static partial class ModCreator
{
internal static partial class Creator
/// <summary>
/// Create and return a new directory based on the given directory and name, that is <br/>
/// - Not Empty.<br/>
/// - Unique, by appending (digit) for duplicates.<br/>
/// - Containing no symbols invalid for FFXIV or windows paths.<br/>
/// </summary>
/// <param name="outDirectory"></param>
/// <param name="modListName"></param>
/// <param name="create"></param>
/// <returns></returns>
/// <exception cref="IOException"></exception>
public static DirectoryInfo CreateModFolder( DirectoryInfo outDirectory, string modListName, bool create = true )
{
/// <summary>
/// Create and return a new directory based on the given directory and name, that is <br/>
/// - Not Empty.<br/>
/// - Unique, by appending (digit) for duplicates.<br/>
/// - Containing no symbols invalid for FFXIV or windows paths.<br/>
/// </summary>
/// <param name="outDirectory"></param>
/// <param name="modListName"></param>
/// <param name="create"></param>
/// <returns></returns>
/// <exception cref="IOException"></exception>
public static DirectoryInfo CreateModFolder( DirectoryInfo outDirectory, string modListName, bool create = true )
var name = modListName;
if( name.Length == 0 )
{
var name = modListName;
if( name.Length == 0 )
{
name = "_";
}
var newModFolderBase = NewOptionDirectory( outDirectory, name );
var newModFolder = newModFolderBase.FullName.ObtainUniqueFile();
if( newModFolder.Length == 0 )
{
throw new IOException( "Could not create mod folder: too many folders of the same name exist." );
}
if( create )
{
Directory.CreateDirectory( newModFolder );
}
return new DirectoryInfo( newModFolder );
name = "_";
}
/// <summary>
/// Create the name for a group or option subfolder based on its parent folder and given name.
/// subFolderName should never be empty, and the result is unique and contains no invalid symbols.
/// </summary>
public static DirectoryInfo? NewSubFolderName( DirectoryInfo parentFolder, string subFolderName )
var newModFolderBase = NewOptionDirectory( outDirectory, name );
var newModFolder = newModFolderBase.FullName.ObtainUniqueFile();
if( newModFolder.Length == 0 )
{
var newModFolderBase = NewOptionDirectory( parentFolder, subFolderName );
var newModFolder = newModFolderBase.FullName.ObtainUniqueFile();
return newModFolder.Length == 0 ? null : new DirectoryInfo( newModFolder );
throw new IOException( "Could not create mod folder: too many folders of the same name exist." );
}
/// <summary> Create a file for an option group from given data. </summary>
public static void CreateOptionGroup( DirectoryInfo baseFolder, GroupType type, string name,
int priority, int index, uint defaultSettings, string desc, IEnumerable< ISubMod > subMods )
if( create )
{
switch( type )
{
case GroupType.Multi:
{
var group = new MultiModGroup()
{
Name = name,
Description = desc,
Priority = priority,
DefaultSettings = defaultSettings,
};
group.PrioritizedOptions.AddRange( subMods.OfType< SubMod >().Select( ( s, idx ) => ( s, idx ) ) );
Penumbra.SaveService.ImmediateSave(new ModSaveGroup(baseFolder, group, index));
break;
}
case GroupType.Single:
{
var group = new SingleModGroup()
{
Name = name,
Description = desc,
Priority = priority,
DefaultSettings = defaultSettings,
};
group.OptionData.AddRange( subMods.OfType< SubMod >() );
Penumbra.SaveService.ImmediateSave(new ModSaveGroup(baseFolder, group, index));
break;
}
}
Directory.CreateDirectory( newModFolder );
}
/// <summary> Create the data for a given sub mod from its data and the folder it is based on. </summary>
public static ISubMod CreateSubMod( DirectoryInfo baseFolder, DirectoryInfo optionFolder, OptionList option )
{
var list = optionFolder.EnumerateFiles( "*.*", SearchOption.AllDirectories )
.Select( f => ( Utf8GamePath.FromFile( f, optionFolder, out var gamePath, true ), gamePath, new FullPath( f ) ) )
.Where( t => t.Item1 );
var mod = new SubMod( null! ) // Mod is irrelevant here, only used for saving.
{
Name = option.Name,
Description = option.Description,
};
foreach( var (_, gamePath, file) in list )
{
mod.FileData.TryAdd( gamePath, file );
}
mod.IncorporateMetaChanges( baseFolder, true );
return mod;
}
/// <summary> Create an empty sub mod for single groups with None options. </summary>
internal static ISubMod CreateEmptySubMod( string name )
=> new SubMod( null! ) // Mod is irrelevant here, only used for saving.
{
Name = name,
};
/// <summary>
/// Create the default data file from all unused files that were not handled before
/// and are used in sub mods.
/// </summary>
internal static void CreateDefaultFiles( DirectoryInfo directory )
{
var mod = new Mod( directory );
mod.Reload( Penumbra.ModManager, false, out _ );
foreach( var file in mod.FindUnusedFiles() )
{
if( Utf8GamePath.FromFile( new FileInfo( file.FullName ), directory, out var gamePath, true ) )
mod._default.FileData.TryAdd( gamePath, file );
}
mod._default.IncorporateMetaChanges( directory, true );
Penumbra.SaveService.ImmediateSave(new ModSaveGroup(mod, -1));
}
/// <summary> Return the name of a new valid directory based on the base directory and the given name. </summary>
public static DirectoryInfo NewOptionDirectory( DirectoryInfo baseDir, string optionName )
=> new(Path.Combine( baseDir.FullName, ReplaceBadXivSymbols( optionName ) ));
/// <summary> Normalize for nicer names, and remove invalid symbols or invalid paths. </summary>
public static string ReplaceBadXivSymbols( string s, string replacement = "_" )
{
switch( s )
{
case ".": return replacement;
case "..": return replacement + replacement;
}
StringBuilder sb = new(s.Length);
foreach( var c in s.Normalize( NormalizationForm.FormKC ) )
{
if( c.IsInvalidInPath() )
{
sb.Append( replacement );
}
else
{
sb.Append( c );
}
}
return sb.ToString();
}
public static void SplitMultiGroups( DirectoryInfo baseDir )
{
var mod = new Mod( baseDir );
var files = mod.GroupFiles.ToList();
var idx = 0;
var reorder = false;
foreach( var groupFile in files )
{
++idx;
try
{
if( reorder )
{
var newName = $"{baseDir.FullName}\\group_{idx:D3}{groupFile.Name[ 9.. ]}";
Penumbra.Log.Debug( $"Moving {groupFile.Name} to {Path.GetFileName( newName )} due to reordering after multi group split." );
groupFile.MoveTo( newName, false );
}
}
catch( Exception ex )
{
throw new Exception( "Could not reorder group file after splitting multi group on .pmp import.", ex );
}
try
{
var json = JObject.Parse( File.ReadAllText( groupFile.FullName ) );
if( json[ nameof( IModGroup.Type ) ]?.ToObject< GroupType >() is not GroupType.Multi )
{
continue;
}
var name = json[ nameof( IModGroup.Name ) ]?.ToObject< string >() ?? string.Empty;
if( name.Length == 0 )
{
continue;
}
var options = json[ "Options" ]?.Children().ToList();
if( options == null )
{
continue;
}
if( options.Count <= IModGroup.MaxMultiOptions )
{
continue;
}
Penumbra.Log.Information( $"Splitting multi group {name} in {mod.Name} due to {options.Count} being too many options." );
var clone = json.DeepClone();
reorder = true;
foreach( var o in options.Skip( IModGroup.MaxMultiOptions ) )
{
o.Remove();
}
var newOptions = clone[ "Options" ]!.Children().ToList();
foreach( var o in newOptions.Take( IModGroup.MaxMultiOptions ) )
{
o.Remove();
}
var match = DuplicateNumber().Match( name );
var startNumber = match.Success ? int.Parse( match.Groups[ 0 ].Value ) : 1;
name = match.Success ? name[ ..4 ] : name;
var oldName = $"{name}, Part {startNumber}";
var oldPath = $"{baseDir.FullName}\\group_{idx:D3}_{oldName.RemoveInvalidPathSymbols().ToLowerInvariant()}.json";
var newName = $"{name}, Part {startNumber + 1}";
var newPath = $"{baseDir.FullName}\\group_{++idx:D3}_{newName.RemoveInvalidPathSymbols().ToLowerInvariant()}.json";
json[ nameof( IModGroup.Name ) ] = oldName;
clone[ nameof( IModGroup.Name ) ] = newName;
clone[ nameof( IModGroup.DefaultSettings ) ] = 0u;
Penumbra.Log.Debug( $"Writing the first {IModGroup.MaxMultiOptions} options to {Path.GetFileName( oldPath )} after split." );
using( var oldFile = File.CreateText( oldPath ) )
{
using var j = new JsonTextWriter( oldFile )
{
Formatting = Formatting.Indented,
};
json.WriteTo( j );
}
Penumbra.Log.Debug( $"Writing the remaining {options.Count - IModGroup.MaxMultiOptions} options to {Path.GetFileName( newPath )} after split." );
using( var newFile = File.CreateText( newPath ) )
{
using var j = new JsonTextWriter( newFile )
{
Formatting = Formatting.Indented,
};
clone.WriteTo( j );
}
Penumbra.Log.Debug(
$"Deleting the old group file at {groupFile.Name} after splitting it into {Path.GetFileName( oldPath )} and {Path.GetFileName( newPath )}." );
groupFile.Delete();
}
catch( Exception ex )
{
throw new Exception( $"Could not split multi group file {groupFile.Name} on .pmp import.", ex );
}
}
}
[GeneratedRegex( @", Part (\d+)$", RegexOptions.NonBacktracking )]
private static partial Regex DuplicateNumber();
return new DirectoryInfo( newModFolder );
}
/// <summary>
/// Create the name for a group or option subfolder based on its parent folder and given name.
/// subFolderName should never be empty, and the result is unique and contains no invalid symbols.
/// </summary>
public static DirectoryInfo? NewSubFolderName( DirectoryInfo parentFolder, string subFolderName )
{
var newModFolderBase = NewOptionDirectory( parentFolder, subFolderName );
var newModFolder = newModFolderBase.FullName.ObtainUniqueFile();
return newModFolder.Length == 0 ? null : new DirectoryInfo( newModFolder );
}
/// <summary> Create a file for an option group from given data. </summary>
public static void CreateOptionGroup( DirectoryInfo baseFolder, GroupType type, string name,
int priority, int index, uint defaultSettings, string desc, IEnumerable< ISubMod > subMods )
{
switch( type )
{
case GroupType.Multi:
{
var group = new MultiModGroup()
{
Name = name,
Description = desc,
Priority = priority,
DefaultSettings = defaultSettings,
};
group.PrioritizedOptions.AddRange( subMods.OfType< SubMod >().Select( ( s, idx ) => ( s, idx ) ) );
Penumbra.SaveService.ImmediateSave(new ModSaveGroup(baseFolder, group, index));
break;
}
case GroupType.Single:
{
var group = new SingleModGroup()
{
Name = name,
Description = desc,
Priority = priority,
DefaultSettings = defaultSettings,
};
group.OptionData.AddRange( subMods.OfType< SubMod >() );
Penumbra.SaveService.ImmediateSave(new ModSaveGroup(baseFolder, group, index));
break;
}
}
}
/// <summary> Create the data for a given sub mod from its data and the folder it is based on. </summary>
public static ISubMod CreateSubMod( DirectoryInfo baseFolder, DirectoryInfo optionFolder, OptionList option )
{
var list = optionFolder.EnumerateFiles( "*.*", SearchOption.AllDirectories )
.Select( f => ( Utf8GamePath.FromFile( f, optionFolder, out var gamePath, true ), gamePath, new FullPath( f ) ) )
.Where( t => t.Item1 );
var mod = new SubMod( null! ) // Mod is irrelevant here, only used for saving.
{
Name = option.Name,
Description = option.Description,
};
foreach( var (_, gamePath, file) in list )
{
mod.FileData.TryAdd( gamePath, file );
}
mod.IncorporateMetaChanges( baseFolder, true );
return mod;
}
/// <summary> Create an empty sub mod for single groups with None options. </summary>
internal static ISubMod CreateEmptySubMod( string name )
=> new SubMod( null! ) // Mod is irrelevant here, only used for saving.
{
Name = name,
};
/// <summary>
/// Create the default data file from all unused files that were not handled before
/// and are used in sub mods.
/// </summary>
internal static void CreateDefaultFiles( DirectoryInfo directory )
{
var mod = new Mod( directory );
mod.Reload( Penumbra.ModManager, false, out _ );
foreach( var file in mod.FindUnusedFiles() )
{
if( Utf8GamePath.FromFile( new FileInfo( file.FullName ), directory, out var gamePath, true ) )
mod._default.FileData.TryAdd( gamePath, file );
}
mod._default.IncorporateMetaChanges( directory, true );
Penumbra.SaveService.ImmediateSave(new ModSaveGroup(mod, -1));
}
/// <summary> Return the name of a new valid directory based on the base directory and the given name. </summary>
public static DirectoryInfo NewOptionDirectory( DirectoryInfo baseDir, string optionName )
=> new(Path.Combine( baseDir.FullName, ReplaceBadXivSymbols( optionName ) ));
/// <summary> Normalize for nicer names, and remove invalid symbols or invalid paths. </summary>
public static string ReplaceBadXivSymbols( string s, string replacement = "_" )
{
switch( s )
{
case ".": return replacement;
case "..": return replacement + replacement;
}
StringBuilder sb = new(s.Length);
foreach( var c in s.Normalize( NormalizationForm.FormKC ) )
{
if( c.IsInvalidInPath() )
{
sb.Append( replacement );
}
else
{
sb.Append( c );
}
}
return sb.ToString();
}
public static void SplitMultiGroups( DirectoryInfo baseDir )
{
var mod = new Mod( baseDir );
var files = mod.GroupFiles.ToList();
var idx = 0;
var reorder = false;
foreach( var groupFile in files )
{
++idx;
try
{
if( reorder )
{
var newName = $"{baseDir.FullName}\\group_{idx:D3}{groupFile.Name[ 9.. ]}";
Penumbra.Log.Debug( $"Moving {groupFile.Name} to {Path.GetFileName( newName )} due to reordering after multi group split." );
groupFile.MoveTo( newName, false );
}
}
catch( Exception ex )
{
throw new Exception( "Could not reorder group file after splitting multi group on .pmp import.", ex );
}
try
{
var json = JObject.Parse( File.ReadAllText( groupFile.FullName ) );
if( json[ nameof( IModGroup.Type ) ]?.ToObject< GroupType >() is not GroupType.Multi )
{
continue;
}
var name = json[ nameof( IModGroup.Name ) ]?.ToObject< string >() ?? string.Empty;
if( name.Length == 0 )
{
continue;
}
var options = json[ "Options" ]?.Children().ToList();
if( options == null )
{
continue;
}
if( options.Count <= IModGroup.MaxMultiOptions )
{
continue;
}
Penumbra.Log.Information( $"Splitting multi group {name} in {mod.Name} due to {options.Count} being too many options." );
var clone = json.DeepClone();
reorder = true;
foreach( var o in options.Skip( IModGroup.MaxMultiOptions ) )
{
o.Remove();
}
var newOptions = clone[ "Options" ]!.Children().ToList();
foreach( var o in newOptions.Take( IModGroup.MaxMultiOptions ) )
{
o.Remove();
}
var match = DuplicateNumber().Match( name );
var startNumber = match.Success ? int.Parse( match.Groups[ 0 ].Value ) : 1;
name = match.Success ? name[ ..4 ] : name;
var oldName = $"{name}, Part {startNumber}";
var oldPath = $"{baseDir.FullName}\\group_{idx:D3}_{oldName.RemoveInvalidPathSymbols().ToLowerInvariant()}.json";
var newName = $"{name}, Part {startNumber + 1}";
var newPath = $"{baseDir.FullName}\\group_{++idx:D3}_{newName.RemoveInvalidPathSymbols().ToLowerInvariant()}.json";
json[ nameof( IModGroup.Name ) ] = oldName;
clone[ nameof( IModGroup.Name ) ] = newName;
clone[ nameof( IModGroup.DefaultSettings ) ] = 0u;
Penumbra.Log.Debug( $"Writing the first {IModGroup.MaxMultiOptions} options to {Path.GetFileName( oldPath )} after split." );
using( var oldFile = File.CreateText( oldPath ) )
{
using var j = new JsonTextWriter( oldFile )
{
Formatting = Formatting.Indented,
};
json.WriteTo( j );
}
Penumbra.Log.Debug( $"Writing the remaining {options.Count - IModGroup.MaxMultiOptions} options to {Path.GetFileName( newPath )} after split." );
using( var newFile = File.CreateText( newPath ) )
{
using var j = new JsonTextWriter( newFile )
{
Formatting = Formatting.Indented,
};
clone.WriteTo( j );
}
Penumbra.Log.Debug(
$"Deleting the old group file at {groupFile.Name} after splitting it into {Path.GetFileName( oldPath )} and {Path.GetFileName( newPath )}." );
groupFile.Delete();
}
catch( Exception ex )
{
throw new Exception( $"Could not split multi group file {groupFile.Name} on .pmp import.", ex );
}
}
}
[GeneratedRegex(@", Part (\d+)$", RegexOptions.NonBacktracking )]
private static partial Regex DuplicateNumber();
}

View file

@ -114,13 +114,13 @@ public partial class Mod
_default.WriteTexToolsMeta(ModPath);
foreach (var group in Groups)
{
var dir = Creator.NewOptionDirectory(ModPath, group.Name);
var dir = ModCreator.NewOptionDirectory(ModPath, group.Name);
if (!dir.Exists)
dir.Create();
foreach (var option in group.OfType<SubMod>())
{
var optionDir = Creator.NewOptionDirectory(dir, option.Name);
var optionDir = ModCreator.NewOptionDirectory(dir, option.Name);
if (!optionDir.Exists)
optionDir.Create();

View file

@ -1,77 +0,0 @@
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using Penumbra.Services;
using Penumbra.Util;
namespace Penumbra.Mods;
public sealed partial class Mod
{
public long ImportDate { get; internal set; } = DateTimeOffset.UnixEpoch.ToUnixTimeMilliseconds();
public IReadOnlyList<string> LocalTags { get; private set; } = Array.Empty<string>();
public string Note { get; internal set; } = string.Empty;
public bool Favorite { get; internal set; } = false;
internal ModDataChangeType UpdateTags(IEnumerable<string>? newModTags, IEnumerable<string>? newLocalTags)
{
if (newModTags == null && newLocalTags == null)
return 0;
ModDataChangeType type = 0;
if (newModTags != null)
{
var modTags = newModTags.Where(t => t.Length > 0).Distinct().ToArray();
if (!modTags.SequenceEqual(ModTags))
{
newLocalTags ??= LocalTags;
ModTags = modTags;
type |= ModDataChangeType.ModTags;
}
}
if (newLocalTags != null)
{
var localTags = newLocalTags!.Where(t => t.Length > 0 && !ModTags.Contains(t)).Distinct().ToArray();
if (!localTags.SequenceEqual(LocalTags))
{
LocalTags = localTags;
type |= ModDataChangeType.LocalTags;
}
}
return type;
}
internal readonly struct ModData : ISavable
{
public const int FileVersion = 3;
private readonly Mod _mod;
public ModData(Mod mod)
=> _mod = mod;
public string ToFilename(FilenameService fileNames)
=> fileNames.LocalDataFile(_mod);
public void Save(StreamWriter writer)
{
var jObject = new JObject
{
{ nameof(FileVersion), JToken.FromObject(FileVersion) },
{ nameof(ImportDate), JToken.FromObject(_mod.ImportDate) },
{ nameof(LocalTags), JToken.FromObject(_mod.LocalTags) },
{ nameof(Note), JToken.FromObject(_mod.Note) },
{ nameof(Favorite), JToken.FromObject(_mod.Favorite) },
};
using var jWriter = new JsonTextWriter(writer) { Formatting = Formatting.Indented };
jObject.WriteTo(jWriter);
}
}
}

View file

@ -1,246 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OtterGui;
using Penumbra.Api.Enums;
using Penumbra.String.Classes;
namespace Penumbra.Mods;
public sealed partial class Mod
{
public static partial class Migration
{
[GeneratedRegex(@"group_\d{3}_", RegexOptions.Compiled | RegexOptions.NonBacktracking | RegexOptions.ExplicitCapture)]
private static partial Regex GroupRegex();
[GeneratedRegex("^group_", RegexOptions.Compiled)]
private static partial Regex GroupStartRegex();
public static bool Migrate(Mod mod, JObject json, ref uint fileVersion)
=> MigrateV0ToV1(mod, json, ref fileVersion) || MigrateV1ToV2(mod, ref fileVersion) || MigrateV2ToV3(mod, ref fileVersion);
private static bool MigrateV2ToV3(Mod _, ref uint fileVersion)
{
if (fileVersion > 2)
return false;
// Remove import time.
fileVersion = 3;
return true;
}
private static bool MigrateV1ToV2(Mod mod, ref uint fileVersion)
{
if (fileVersion > 1)
return false;
if (!mod.GroupFiles.All(g => GroupRegex().IsMatch(g.Name)))
foreach (var (group, index) in mod.GroupFiles.WithIndex().ToArray())
{
var newName = GroupStartRegex().Replace(group.Name, $"group_{index + 1:D3}_");
try
{
if (newName != group.Name)
group.MoveTo(Path.Combine(group.DirectoryName ?? string.Empty, newName), false);
}
catch (Exception e)
{
Penumbra.Log.Error($"Could not rename group file {group.Name} to {newName} during migration:\n{e}");
}
}
fileVersion = 2;
return true;
}
private static bool MigrateV0ToV1(Mod mod, JObject json, ref uint fileVersion)
{
if (fileVersion > 0)
return false;
var swaps = json["FileSwaps"]?.ToObject<Dictionary<Utf8GamePath, FullPath>>()
?? new Dictionary<Utf8GamePath, FullPath>();
var groups = json["Groups"]?.ToObject<Dictionary<string, OptionGroupV0>>() ?? new Dictionary<string, OptionGroupV0>();
var priority = 1;
var seenMetaFiles = new HashSet<FullPath>();
foreach (var group in groups.Values)
ConvertGroup(mod, group, ref priority, seenMetaFiles);
foreach (var unusedFile in mod.FindUnusedFiles().Where(f => !seenMetaFiles.Contains(f)))
{
if (unusedFile.ToGamePath(mod.ModPath, out var gamePath)
&& !mod._default.FileData.TryAdd(gamePath, unusedFile))
Penumbra.Log.Error($"Could not add {gamePath} because it already points to {mod._default.FileData[gamePath]}.");
}
mod._default.FileSwapData.Clear();
mod._default.FileSwapData.EnsureCapacity(swaps.Count);
foreach (var (gamePath, swapPath) in swaps)
mod._default.FileSwapData.Add(gamePath, swapPath);
mod._default.IncorporateMetaChanges(mod.ModPath, true);
foreach (var (_, index) in mod.Groups.WithIndex())
Penumbra.SaveService.ImmediateSave(new ModSaveGroup(mod, index));
// Delete meta files.
foreach (var file in seenMetaFiles.Where(f => f.Exists))
{
try
{
File.Delete(file.FullName);
}
catch (Exception e)
{
Penumbra.Log.Warning($"Could not delete meta file {file.FullName} during migration:\n{e}");
}
}
// Delete old meta files.
var oldMetaFile = Path.Combine(mod.ModPath.FullName, "metadata_manipulations.json");
if (File.Exists(oldMetaFile))
try
{
File.Delete(oldMetaFile);
}
catch (Exception e)
{
Penumbra.Log.Warning($"Could not delete old meta file {oldMetaFile} during migration:\n{e}");
}
fileVersion = 1;
Penumbra.SaveService.ImmediateSave(new ModSaveGroup(mod, -1));
return true;
}
private static void ConvertGroup(Mod mod, OptionGroupV0 group, ref int priority, HashSet<FullPath> seenMetaFiles)
{
if (group.Options.Count == 0)
return;
switch (group.SelectionType)
{
case GroupType.Multi:
var optionPriority = 0;
var newMultiGroup = new MultiModGroup()
{
Name = group.GroupName,
Priority = priority++,
Description = string.Empty,
};
mod._groups.Add(newMultiGroup);
foreach (var option in group.Options)
newMultiGroup.PrioritizedOptions.Add((SubModFromOption(mod, option, seenMetaFiles), optionPriority++));
break;
case GroupType.Single:
if (group.Options.Count == 1)
{
AddFilesToSubMod(mod._default, mod.ModPath, group.Options[0], seenMetaFiles);
return;
}
var newSingleGroup = new SingleModGroup()
{
Name = group.GroupName,
Priority = priority++,
Description = string.Empty,
};
mod._groups.Add(newSingleGroup);
foreach (var option in group.Options)
newSingleGroup.OptionData.Add(SubModFromOption(mod, option, seenMetaFiles));
break;
}
}
private static void AddFilesToSubMod(SubMod mod, DirectoryInfo basePath, OptionV0 option, HashSet<FullPath> seenMetaFiles)
{
foreach (var (relPath, gamePaths) in option.OptionFiles)
{
var fullPath = new FullPath(basePath, relPath);
foreach (var gamePath in gamePaths)
mod.FileData.TryAdd(gamePath, fullPath);
if (fullPath.Extension is ".meta" or ".rgsp")
seenMetaFiles.Add(fullPath);
}
}
private static SubMod SubModFromOption(Mod mod, OptionV0 option, HashSet<FullPath> seenMetaFiles)
{
var subMod = new SubMod(mod) { Name = option.OptionName };
AddFilesToSubMod(subMod, mod.ModPath, option, seenMetaFiles);
subMod.IncorporateMetaChanges(mod.ModPath, false);
return subMod;
}
private struct OptionV0
{
public string OptionName = string.Empty;
public string OptionDesc = string.Empty;
[JsonProperty(ItemConverterType = typeof(SingleOrArrayConverter<Utf8GamePath>))]
public Dictionary<Utf8RelPath, HashSet<Utf8GamePath>> OptionFiles = new();
public OptionV0()
{ }
}
private struct OptionGroupV0
{
public string GroupName = string.Empty;
[JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
public GroupType SelectionType = GroupType.Single;
public List<OptionV0> Options = new();
public OptionGroupV0()
{ }
}
// Not used anymore, but required for migration.
private class SingleOrArrayConverter<T> : JsonConverter
{
public override bool CanConvert(Type objectType)
=> objectType == typeof(HashSet<T>);
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
var token = JToken.Load(reader);
if (token.Type == JTokenType.Array)
return token.ToObject<HashSet<T>>() ?? new HashSet<T>();
var tmp = token.ToObject<T>();
return tmp != null
? new HashSet<T> { tmp }
: new HashSet<T>();
}
public override bool CanWrite
=> true;
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
writer.WriteStartArray();
if (value != null)
{
var v = (HashSet<T>)value;
foreach (var val in v)
serializer.Serialize(writer, val?.ToString());
}
writer.WriteEndArray();
}
}
}
}

View file

@ -1,59 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OtterGui.Classes;
using Penumbra.Services;
using Penumbra.Util;
namespace Penumbra.Mods;
public sealed partial class Mod : IMod
{
public static readonly TemporaryMod ForcedFiles = new()
{
Name = "Forced Files",
Index = -1,
Priority = int.MaxValue,
};
public LowerString Name { get; internal set; } = "New Mod";
public LowerString Author { get; internal set; } = LowerString.Empty;
public string Description { get; internal set; } = string.Empty;
public string Version { get; internal set; } = string.Empty;
public string Website { get; internal set; } = string.Empty;
public IReadOnlyList<string> ModTags { get; internal set; } = Array.Empty<string>();
public override string ToString()
=> Name.Text;
internal readonly struct ModMeta : ISavable
{
public const uint FileVersion = 3;
private readonly Mod _mod;
public ModMeta(Mod mod)
=> _mod = mod;
public string ToFilename(FilenameService fileNames)
=> fileNames.ModMetaPath(_mod);
public void Save(StreamWriter writer)
{
var jObject = new JObject
{
{ nameof(FileVersion), JToken.FromObject(FileVersion) },
{ nameof(Name), JToken.FromObject(_mod.Name) },
{ nameof(Author), JToken.FromObject(_mod.Author) },
{ nameof(Description), JToken.FromObject(_mod.Description) },
{ nameof(Version), JToken.FromObject(_mod.Version) },
{ nameof(Website), JToken.FromObject(_mod.Website) },
{ nameof(ModTags), JToken.FromObject(_mod.ModTags) },
};
using var jWriter = new JsonTextWriter(writer) { Formatting = Formatting.Indented };
jObject.WriteTo(jWriter);
}
}
}

35
Penumbra/Mods/Mod.cs Normal file
View file

@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using OtterGui.Classes;
namespace Penumbra.Mods;
public sealed partial class Mod : IMod
{
public static readonly TemporaryMod ForcedFiles = new()
{
Name = "Forced Files",
Index = -1,
Priority = int.MaxValue,
};
// Meta Data
public LowerString Name { get; internal set; } = "New Mod";
public LowerString Author { get; internal set; } = LowerString.Empty;
public string Description { get; internal set; } = string.Empty;
public string Version { get; internal set; } = string.Empty;
public string Website { get; internal set; } = string.Empty;
public IReadOnlyList<string> ModTags { get; internal set; } = Array.Empty<string>();
// Local Data
public long ImportDate { get; internal set; } = DateTimeOffset.UnixEpoch.ToUnixTimeMilliseconds();
public IReadOnlyList<string> LocalTags { get; internal set; } = Array.Empty<string>();
public string Note { get; internal set; } = string.Empty;
public bool Favorite { get; internal set; } = false;
// Access
public override string ToString()
=> Name.Text;
}

View file

@ -1,6 +1,6 @@
using System.Collections.Generic;
namespace Penumbra.Mods.Manager;
namespace Penumbra.Mods;
public class ModCache
{

View file

@ -0,0 +1,67 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Penumbra.Mods.Manager;
using Penumbra.Services;
using Penumbra.Util;
namespace Penumbra.Mods;
public readonly struct ModLocalData : ISavable
{
public const int FileVersion = 3;
private readonly Mod _mod;
public ModLocalData(Mod mod)
=> _mod = mod;
public string ToFilename(FilenameService fileNames)
=> fileNames.LocalDataFile(_mod);
public void Save(StreamWriter writer)
{
var jObject = new JObject
{
{ nameof(FileVersion), JToken.FromObject(FileVersion) },
{ nameof(Mod.ImportDate), JToken.FromObject(_mod.ImportDate) },
{ nameof(Mod.LocalTags), JToken.FromObject(_mod.LocalTags) },
{ nameof(Mod.Note), JToken.FromObject(_mod.Note) },
{ nameof(Mod.Favorite), JToken.FromObject(_mod.Favorite) },
};
using var jWriter = new JsonTextWriter(writer) { Formatting = Formatting.Indented };
jObject.WriteTo(jWriter);
}
internal static ModDataChangeType UpdateTags(Mod mod, IEnumerable<string>? newModTags, IEnumerable<string>? newLocalTags)
{
if (newModTags == null && newLocalTags == null)
return 0;
ModDataChangeType type = 0;
if (newModTags != null)
{
var modTags = newModTags.Where(t => t.Length > 0).Distinct().ToArray();
if (!modTags.SequenceEqual(mod.ModTags))
{
newLocalTags ??= mod.LocalTags;
mod.ModTags = modTags;
type |= ModDataChangeType.ModTags;
}
}
if (newLocalTags != null)
{
var localTags = newLocalTags!.Where(t => t.Length > 0 && !mod.ModTags.Contains(t)).Distinct().ToArray();
if (!localTags.SequenceEqual(mod.LocalTags))
{
mod.LocalTags = localTags;
type |= ModDataChangeType.LocalTags;
}
}
return type;
}
}

36
Penumbra/Mods/ModMeta.cs Normal file
View file

@ -0,0 +1,36 @@
using System.IO;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Penumbra.Services;
using Penumbra.Util;
namespace Penumbra.Mods;
public readonly struct ModMeta : ISavable
{
public const uint FileVersion = 3;
private readonly Mod _mod;
public ModMeta(Mod mod)
=> _mod = mod;
public string ToFilename(FilenameService fileNames)
=> fileNames.ModMetaPath(_mod);
public void Save(StreamWriter writer)
{
var jObject = new JObject
{
{ nameof(FileVersion), JToken.FromObject(FileVersion) },
{ nameof(Mod.Name), JToken.FromObject(_mod.Name) },
{ nameof(Mod.Author), JToken.FromObject(_mod.Author) },
{ nameof(Mod.Description), JToken.FromObject(_mod.Description) },
{ nameof(Mod.Version), JToken.FromObject(_mod.Version) },
{ nameof(Mod.Website), JToken.FromObject(_mod.Website) },
{ nameof(Mod.ModTags), JToken.FromObject(_mod.ModTags) },
};
using var jWriter = new JsonTextWriter(writer) { Formatting = Formatting.Indented };
jObject.WriteTo(jWriter);
}
}

View file

@ -50,7 +50,7 @@ public class TemporaryMod : IMod
DirectoryInfo? dir = null;
try
{
dir = Mod.Creator.CreateModFolder( Penumbra.ModManager.BasePath, collection.Name );
dir = ModCreator.CreateModFolder( Penumbra.ModManager.BasePath, collection.Name );
var fileDir = Directory.CreateDirectory( Path.Combine( dir.FullName, "files" ) );
modManager.DataEditor.CreateMeta( dir, collection.Name, character ?? Penumbra.Config.DefaultModAuthor,
$"Mod generated from temporary collection {collection.Name} for {character ?? "Unknown Character"}.", null, null );

View file

@ -24,7 +24,6 @@ using Penumbra.GameData.Actors;
using Penumbra.GameData.Data;
using Penumbra.Interop.ResourceLoading;
using Penumbra.Interop.PathResolving;
using Penumbra.Mods;
using CharacterUtility = Penumbra.Interop.Services.CharacterUtility;
using DalamudUtil = Dalamud.Utility.Util;
using ResidentResourceManager = Penumbra.Interop.Services.ResidentResourceManager;

View file

@ -269,9 +269,9 @@ public class ItemSwapTab : IDisposable, ITab
private void CreateMod()
{
var newDir = Mod.Creator.CreateModFolder(_modManager.BasePath, _newModName);
var newDir = ModCreator.CreateModFolder(_modManager.BasePath, _newModName);
_modManager.DataEditor.CreateMeta(newDir, _newModName, _config.DefaultModAuthor, CreateDescription(), "1.0", string.Empty);
Mod.Creator.CreateDefaultFiles(newDir);
ModCreator.CreateDefaultFiles(newDir);
_modManager.AddMod(newDir);
if (!_swapData.WriteMod(_modManager, _modManager[^1],
_useFileSwaps ? ItemSwapContainer.WriteType.UseSwaps : ItemSwapContainer.WriteType.NoSwaps))
@ -290,7 +290,7 @@ public class ItemSwapTab : IDisposable, ITab
try
{
optionFolderName =
Mod.Creator.NewSubFolderName(new DirectoryInfo(Path.Combine(_mod.ModPath.FullName, _selectedGroup?.Name ?? _newGroupName)),
ModCreator.NewSubFolderName(new DirectoryInfo(Path.Combine(_mod.ModPath.FullName, _selectedGroup?.Name ?? _newGroupName)),
_newOptionName);
if (optionFolderName?.Exists == true)
throw new Exception($"The folder {optionFolderName.FullName} for the option already exists.");

View file

@ -217,13 +217,13 @@ public partial class ModEditWindow
var fullName = subMod.FullName;
if (fullName.EndsWith(": " + name))
{
path = Mod.Creator.NewOptionDirectory(path, fullName[..^(name.Length + 2)]);
path = Mod.Creator.NewOptionDirectory(path, name);
path = ModCreator.NewOptionDirectory(path, fullName[..^(name.Length + 2)]);
path = ModCreator.NewOptionDirectory(path, name);
subDirs = 2;
}
else
{
path = Mod.Creator.NewOptionDirectory(path, fullName);
path = ModCreator.NewOptionDirectory(path, fullName);
subDirs = 1;
}

View file

@ -131,9 +131,9 @@ public sealed partial class ModFileSystemSelector : FileSystemSelector<Mod, ModF
if (ImGuiUtil.OpenNameField("Create New Mod", ref _newModName))
try
{
var newDir = Mod.Creator.CreateModFolder(Penumbra.ModManager.BasePath, _newModName);
var newDir = ModCreator.CreateModFolder(Penumbra.ModManager.BasePath, _newModName);
_modManager.DataEditor.CreateMeta(newDir, _newModName, Penumbra.Config.DefaultModAuthor, string.Empty, "1.0", string.Empty);
Mod.Creator.CreateDefaultFiles(newDir);
ModCreator.CreateDefaultFiles(newDir);
_modManager.AddMod(newDir);
_newModName = string.Empty;
}

View file

@ -30,20 +30,21 @@ public interface ISavable
public class SaveService
{
private readonly Logger _log;
private readonly FilenameService _fileNames;
private readonly FrameworkManager _framework;
public SaveService(Logger log, FilenameService fileNames, FrameworkManager framework)
public readonly FilenameService FileNames;
public SaveService(Logger log, FrameworkManager framework, FilenameService fileNames)
{
_log = log;
_fileNames = fileNames;
_framework = framework;
FileNames = fileNames;
}
/// <summary> Queue a save for the next framework tick. </summary>
public void QueueSave(ISavable value)
{
var file = value.ToFilename(_fileNames);
var file = value.ToFilename(FileNames);
_framework.RegisterDelayed(value.GetType().Name + file, () =>
{
ImmediateSave(value);
@ -53,7 +54,7 @@ public class SaveService
/// <summary> Immediately trigger a save. </summary>
public void ImmediateSave(ISavable value)
{
var name = value.ToFilename(_fileNames);
var name = value.ToFilename(FileNames);
try
{
if (name.Length == 0)
@ -76,7 +77,7 @@ public class SaveService
public void ImmediateDelete(ISavable value)
{
var name = value.ToFilename(_fileNames);
var name = value.ToFilename(FileNames);
try
{
if (name.Length == 0)
@ -99,7 +100,7 @@ public class SaveService
/// <summary> Immediately delete all existing option group files for a mod and save them anew. </summary>
public void SaveAllOptionGroups(Mod mod)
{
foreach (var file in _fileNames.GetOptionGroupFiles(mod))
foreach (var file in FileNames.GetOptionGroupFiles(mod))
{
try
{