mirror of
https://github.com/xivdev/Penumbra.git
synced 2025-12-12 10:17:22 +01:00
Remove some warnings about nullable.
This commit is contained in:
parent
3f9d97f65f
commit
ef2f2cff5c
26 changed files with 330 additions and 352 deletions
|
|
@ -13,10 +13,10 @@ namespace Penumbra.API
|
|||
public ModsController( Plugin plugin ) => _plugin = plugin;
|
||||
|
||||
[Route( HttpVerbs.Get, "/mods" )]
|
||||
public object GetMods()
|
||||
public object? GetMods()
|
||||
{
|
||||
var modManager = Service< ModManager >.Get();
|
||||
return modManager.Mods.ModSettings.Select( x => new
|
||||
return modManager.Mods?.ModSettings.Select( x => new
|
||||
{
|
||||
x.Enabled,
|
||||
x.Priority,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ namespace Penumbra
|
|||
// the below exist just to make saving less cumbersome
|
||||
|
||||
[NonSerialized]
|
||||
private DalamudPluginInterface _pluginInterface;
|
||||
private DalamudPluginInterface? _pluginInterface;
|
||||
|
||||
public void Initialize( DalamudPluginInterface pluginInterface )
|
||||
{
|
||||
|
|
@ -36,7 +36,7 @@ namespace Penumbra
|
|||
|
||||
public void Save()
|
||||
{
|
||||
_pluginInterface.SavePluginConfig( this );
|
||||
_pluginInterface?.SavePluginConfig( this );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -38,12 +38,12 @@ namespace Penumbra.Hooks
|
|||
uint* pResourceHash, char* pPath, void* pUnknown, bool isUnknown );
|
||||
|
||||
// Hooks
|
||||
public IHook< GetResourceSyncPrototype > GetResourceSyncHook { get; private set; }
|
||||
public IHook< GetResourceAsyncPrototype > GetResourceAsyncHook { get; private set; }
|
||||
public IHook< ReadSqpackPrototype > ReadSqpackHook { get; private set; }
|
||||
public IHook< GetResourceSyncPrototype >? GetResourceSyncHook { get; private set; }
|
||||
public IHook< GetResourceAsyncPrototype >? GetResourceAsyncHook { get; private set; }
|
||||
public IHook< ReadSqpackPrototype >? ReadSqpackHook { get; private set; }
|
||||
|
||||
// Unmanaged functions
|
||||
public ReadFilePrototype ReadFile { get; private set; }
|
||||
public ReadFilePrototype? ReadFile { get; private set; }
|
||||
|
||||
|
||||
public bool LogAllFiles = false;
|
||||
|
|
@ -57,7 +57,7 @@ namespace Penumbra.Hooks
|
|||
|
||||
public unsafe void Init()
|
||||
{
|
||||
var scanner = Plugin.PluginInterface.TargetModuleScanner;
|
||||
var scanner = Plugin!.PluginInterface!.TargetModuleScanner;
|
||||
|
||||
var readFileAddress =
|
||||
scanner.ScanText( "E8 ?? ?? ?? ?? 84 C0 0F 84 ?? 00 00 00 4C 8B C3 BA 05" );
|
||||
|
|
@ -108,9 +108,24 @@ namespace Penumbra.Hooks
|
|||
char* pPath,
|
||||
void* pUnknown,
|
||||
bool isUnknown
|
||||
) => isSync
|
||||
? GetResourceSyncHook.OriginalFunction( pFileManager, pCategoryId, pResourceType, pResourceHash, pPath, pUnknown )
|
||||
: GetResourceAsyncHook.OriginalFunction( pFileManager, pCategoryId, pResourceType, pResourceHash, pPath, pUnknown, isUnknown );
|
||||
)
|
||||
{
|
||||
if( isSync )
|
||||
{
|
||||
if( GetResourceSyncHook == null )
|
||||
{
|
||||
PluginLog.Error("[GetResourceHandler] GetResourceSync is null." );
|
||||
return null;
|
||||
}
|
||||
return GetResourceSyncHook.OriginalFunction( pFileManager, pCategoryId, pResourceType, pResourceHash, pPath, pUnknown );
|
||||
}
|
||||
if( GetResourceAsyncHook == null )
|
||||
{
|
||||
PluginLog.Error("[GetResourceHandler] GetResourceAsync is null." );
|
||||
return null;
|
||||
}
|
||||
return GetResourceAsyncHook.OriginalFunction( pFileManager, pCategoryId, pResourceType, pResourceHash, pPath, pUnknown, isUnknown );
|
||||
}
|
||||
|
||||
private unsafe void* GetResourceHandler(
|
||||
bool isSync,
|
||||
|
|
@ -132,12 +147,12 @@ namespace Penumbra.Hooks
|
|||
|
||||
var modManager = Service< ModManager >.Get();
|
||||
|
||||
if( !Plugin.Configuration.IsEnabled || modManager == null )
|
||||
if( !Plugin!.Configuration!.IsEnabled || modManager == null )
|
||||
{
|
||||
return CallOriginalHandler( isSync, pFileManager, pCategoryId, pResourceType, pResourceHash, pPath, pUnknown, isUnknown );
|
||||
}
|
||||
|
||||
var replacementPath = modManager.ResolveSwappedOrReplacementFilePath( gameFsPath );
|
||||
var replacementPath = modManager.ResolveSwappedOrReplacementFilePath( gameFsPath! );
|
||||
|
||||
// path must be < 260 because statically defined array length :(
|
||||
if( replacementPath == null || replacementPath.Length >= 260 )
|
||||
|
|
@ -170,9 +185,9 @@ namespace Penumbra.Hooks
|
|||
|
||||
var isRooted = Path.IsPathRooted( gameFsPath );
|
||||
|
||||
if( gameFsPath == null || gameFsPath.Length >= 260 || !isRooted )
|
||||
if( gameFsPath == null || gameFsPath.Length >= 260 || !isRooted || ReadFile == null)
|
||||
{
|
||||
return ReadSqpackHook.OriginalFunction( pFileHandler, pFileDesc, priority, isSync );
|
||||
return ReadSqpackHook?.OriginalFunction( pFileHandler, pFileDesc, priority, isSync ) ?? 0;
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
|
@ -201,6 +216,12 @@ namespace Penumbra.Hooks
|
|||
return;
|
||||
}
|
||||
|
||||
if( ReadSqpackHook == null || GetResourceSyncHook == null || GetResourceAsyncHook == null)
|
||||
{
|
||||
PluginLog.Error("[GetResourceHandler] Could not activate hooks because at least one was not set." );
|
||||
return;
|
||||
}
|
||||
|
||||
ReadSqpackHook.Activate();
|
||||
GetResourceSyncHook.Activate();
|
||||
GetResourceAsyncHook.Activate();
|
||||
|
|
@ -208,7 +229,7 @@ namespace Penumbra.Hooks
|
|||
ReadSqpackHook.Enable();
|
||||
GetResourceSyncHook.Enable();
|
||||
GetResourceAsyncHook.Enable();
|
||||
|
||||
|
||||
IsEnabled = true;
|
||||
}
|
||||
|
||||
|
|
@ -219,23 +240,16 @@ namespace Penumbra.Hooks
|
|||
return;
|
||||
}
|
||||
|
||||
ReadSqpackHook.Disable();
|
||||
GetResourceSyncHook.Disable();
|
||||
GetResourceAsyncHook.Disable();
|
||||
ReadSqpackHook?.Disable();
|
||||
GetResourceSyncHook?.Disable();
|
||||
GetResourceAsyncHook?.Disable();
|
||||
|
||||
IsEnabled = false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if( IsEnabled )
|
||||
{
|
||||
Disable();
|
||||
}
|
||||
|
||||
// ReadSqpackHook.Disable();
|
||||
// GetResourceSyncHook.Disable();
|
||||
// GetResourceAsyncHook.Disable();
|
||||
Disable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ namespace Penumbra.Hooks
|
|||
|
||||
public SoundShit( Plugin plugin )
|
||||
{
|
||||
var scanner = plugin.PluginInterface.TargetModuleScanner;
|
||||
var scanner = plugin!.PluginInterface!.TargetModuleScanner;
|
||||
|
||||
var fw = plugin.PluginInterface.Framework.Address.BaseAddress;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,36 +5,36 @@ namespace Penumbra.Importer.Models
|
|||
{
|
||||
internal class OptionList
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string ImagePath { get; set; }
|
||||
public List< SimpleMod > ModsJsons { get; set; }
|
||||
public string GroupName { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? ImagePath { get; set; }
|
||||
public List< SimpleMod >? ModsJsons { get; set; }
|
||||
public string? GroupName { get; set; }
|
||||
public SelectType SelectionType { get; set; }
|
||||
public bool IsChecked { get; set; }
|
||||
}
|
||||
|
||||
internal class ModGroup
|
||||
{
|
||||
public string GroupName { get; set; }
|
||||
public string? GroupName { get; set; }
|
||||
public SelectType SelectionType { get; set; }
|
||||
public List< OptionList > OptionList { get; set; }
|
||||
public List< OptionList >? OptionList { get; set; }
|
||||
}
|
||||
|
||||
internal class ModPackPage
|
||||
{
|
||||
public int PageIndex { get; set; }
|
||||
public List< ModGroup > ModGroups { get; set; }
|
||||
public List< ModGroup >? ModGroups { get; set; }
|
||||
}
|
||||
|
||||
internal class ExtendedModPack
|
||||
{
|
||||
public string TTMPVersion { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Author { get; set; }
|
||||
public string Version { get; set; }
|
||||
public string Description { get; set; }
|
||||
public List< ModPackPage > ModPackPages { get; set; }
|
||||
public List< SimpleMod > SimpleModsList { get; set; }
|
||||
public string? TTMPVersion { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Author { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public List< ModPackPage >? ModPackPages { get; set; }
|
||||
public List< SimpleMod >? SimpleModsList { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -4,22 +4,22 @@ namespace Penumbra.Importer.Models
|
|||
{
|
||||
internal class SimpleModPack
|
||||
{
|
||||
public string TTMPVersion { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Author { get; set; }
|
||||
public string Version { get; set; }
|
||||
public string Description { get; set; }
|
||||
public List< SimpleMod > SimpleModsList { get; set; }
|
||||
public string? TTMPVersion { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Author { get; set; }
|
||||
public string? Version { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public List< SimpleMod >? SimpleModsList { get; set; }
|
||||
}
|
||||
|
||||
internal class SimpleMod
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Category { get; set; }
|
||||
public string FullPath { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public string? FullPath { get; set; }
|
||||
public long ModOffset { get; set; }
|
||||
public long ModSize { get; set; }
|
||||
public string DatFile { get; set; }
|
||||
public object ModPackEntry { get; set; }
|
||||
public string? DatFile { get; set; }
|
||||
public object? ModPackEntry { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ namespace Penumbra.Importer
|
|||
}
|
||||
}
|
||||
|
||||
public string CurrentModPack { get; private set; }
|
||||
public string? CurrentModPack { get; private set; }
|
||||
|
||||
public TexToolsImport( DirectoryInfo outDirectory )
|
||||
{
|
||||
|
|
@ -145,6 +145,12 @@ namespace Penumbra.Importer
|
|||
{
|
||||
var modList = JsonConvert.DeserializeObject< SimpleModPack >( modRaw );
|
||||
|
||||
if( modList?.TTMPVersion == null )
|
||||
{
|
||||
PluginLog.Error( "Could not extract V2 Modpack. No version given." );
|
||||
return;
|
||||
}
|
||||
|
||||
if( modList.TTMPVersion.EndsWith( "s" ) )
|
||||
{
|
||||
ImportSimpleV2ModPack( extractedModPack, modList );
|
||||
|
|
@ -164,11 +170,11 @@ namespace Penumbra.Importer
|
|||
// Create a new ModMeta from the TTMP modlist info
|
||||
var modMeta = new ModMeta
|
||||
{
|
||||
Author = modList.Author,
|
||||
Name = modList.Name,
|
||||
Author = modList.Author ?? "Unknown",
|
||||
Name = modList.Name ?? "New Mod",
|
||||
Description = string.IsNullOrEmpty( modList.Description )
|
||||
? "Mod imported from TexTools mod pack"
|
||||
: modList.Description
|
||||
: modList.Description!
|
||||
};
|
||||
|
||||
// Open the mod data file from the modpack as a SqPackStream
|
||||
|
|
@ -181,7 +187,7 @@ namespace Penumbra.Importer
|
|||
File.WriteAllText( Path.Combine( newModFolder.FullName, "meta.json" ),
|
||||
JsonConvert.SerializeObject( modMeta ) );
|
||||
|
||||
ExtractSimpleModList( newModFolder, modList.SimpleModsList, modData );
|
||||
ExtractSimpleModList( newModFolder, modList.SimpleModsList ?? Enumerable.Empty< SimpleMod >(), modData );
|
||||
}
|
||||
|
||||
private void ImportExtendedV2ModPack( ZipFile extractedModPack, string modRaw )
|
||||
|
|
@ -193,12 +199,12 @@ namespace Penumbra.Importer
|
|||
// Create a new ModMeta from the TTMP modlist info
|
||||
var modMeta = new ModMeta
|
||||
{
|
||||
Author = modList.Author,
|
||||
Name = modList.Name,
|
||||
Author = modList.Author ?? "Unknown",
|
||||
Name = modList.Name ?? "New Mod",
|
||||
Description = string.IsNullOrEmpty( modList.Description )
|
||||
? "Mod imported from TexTools mod pack"
|
||||
: modList.Description,
|
||||
Version = modList.Version
|
||||
: modList.Description ?? "",
|
||||
Version = modList.Version ?? ""
|
||||
};
|
||||
|
||||
// Open the mod data file from the modpack as a SqPackStream
|
||||
|
|
@ -222,13 +228,14 @@ namespace Penumbra.Importer
|
|||
}
|
||||
|
||||
// Iterate through all pages
|
||||
foreach( var group in modList.ModPackPages.SelectMany( page => page.ModGroups ) )
|
||||
foreach( var group in modList.ModPackPages.SelectMany( page => page.ModGroups )
|
||||
.Where( group => group.GroupName != null && group.OptionList != null ) )
|
||||
{
|
||||
var groupFolder = new DirectoryInfo( Path.Combine( newModFolder.FullName, group.GroupName.ReplaceInvalidPathSymbols() ) );
|
||||
foreach( var option in group.OptionList )
|
||||
var groupFolder = new DirectoryInfo( Path.Combine( newModFolder.FullName, group.GroupName!.ReplaceInvalidPathSymbols() ) );
|
||||
foreach( var option in group.OptionList!.Where( option => option.Name != null && option.ModsJsons != null ) )
|
||||
{
|
||||
var optionFolder = new DirectoryInfo( Path.Combine( groupFolder.FullName, option.Name.ReplaceInvalidPathSymbols() ) );
|
||||
ExtractSimpleModList( optionFolder, option.ModsJsons, modData );
|
||||
var optionFolder = new DirectoryInfo( Path.Combine( groupFolder.FullName, option.Name!.ReplaceInvalidPathSymbols() ) );
|
||||
ExtractSimpleModList( optionFolder, option.ModsJsons!, modData );
|
||||
}
|
||||
|
||||
AddMeta( newModFolder, groupFolder, group, modMeta );
|
||||
|
|
@ -242,21 +249,21 @@ namespace Penumbra.Importer
|
|||
|
||||
private static void AddMeta( DirectoryInfo baseFolder, DirectoryInfo groupFolder, ModGroup group, ModMeta meta )
|
||||
{
|
||||
var Inf = new InstallerInfo
|
||||
var inf = new InstallerInfo
|
||||
{
|
||||
SelectionType = group.SelectionType,
|
||||
GroupName = group.GroupName,
|
||||
GroupName = group.GroupName!,
|
||||
Options = new List< Option >()
|
||||
};
|
||||
foreach( var opt in group.OptionList )
|
||||
foreach( var opt in group.OptionList! )
|
||||
{
|
||||
var option = new Option
|
||||
{
|
||||
OptionName = opt.Name,
|
||||
OptionDesc = string.IsNullOrEmpty( opt.Description ) ? "" : opt.Description,
|
||||
OptionName = opt.Name!,
|
||||
OptionDesc = string.IsNullOrEmpty( opt.Description ) ? "" : opt.Description!,
|
||||
OptionFiles = new Dictionary< string, HashSet< string > >()
|
||||
};
|
||||
var optDir = new DirectoryInfo( Path.Combine( groupFolder.FullName, opt.Name.ReplaceInvalidPathSymbols() ) );
|
||||
var optDir = new DirectoryInfo( Path.Combine( groupFolder.FullName, opt.Name!.ReplaceInvalidPathSymbols() ) );
|
||||
if( optDir.Exists )
|
||||
{
|
||||
foreach( var file in optDir.EnumerateFiles( "*.*", SearchOption.AllDirectories ) )
|
||||
|
|
@ -266,10 +273,10 @@ namespace Penumbra.Importer
|
|||
}
|
||||
}
|
||||
|
||||
Inf.Options.Add( option );
|
||||
inf.Options.Add( option );
|
||||
}
|
||||
|
||||
meta.Groups.Add( group.GroupName, Inf );
|
||||
meta.Groups.Add( group.GroupName!, inf );
|
||||
}
|
||||
|
||||
private void ImportMetaModPack( FileInfo file )
|
||||
|
|
|
|||
|
|
@ -12,14 +12,14 @@ namespace Penumbra.Models
|
|||
private readonly DirectoryInfo _baseDir;
|
||||
private readonly int _baseDirLength;
|
||||
private readonly ModMeta _mod;
|
||||
private SHA256 _hasher;
|
||||
private SHA256? _hasher;
|
||||
|
||||
private readonly Dictionary< long, List< FileInfo > > _filesBySize = new();
|
||||
|
||||
private ref SHA256 Sha()
|
||||
private SHA256 Sha()
|
||||
{
|
||||
_hasher ??= SHA256.Create();
|
||||
return ref _hasher;
|
||||
return _hasher;
|
||||
}
|
||||
|
||||
public Deduplicator( DirectoryInfo baseDir, ModMeta mod )
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ namespace Penumbra.Models
|
|||
public string OptionName;
|
||||
public string OptionDesc;
|
||||
|
||||
[JsonProperty( ItemConverterType = typeof( SingleOrArrayConverter< string > ) )]
|
||||
[JsonProperty( ItemConverterType = typeof( Util.SingleOrArrayConverter< string > ) )]
|
||||
public Dictionary< string, HashSet< string > > OptionFiles;
|
||||
|
||||
public bool AddFile( string filePath, string gamePath )
|
||||
|
|
|
|||
|
|
@ -6,10 +6,13 @@ namespace Penumbra.Models
|
|||
{
|
||||
public class ModInfo
|
||||
{
|
||||
public string FolderName { get; set; }
|
||||
public ModInfo( ResourceMod mod )
|
||||
=> Mod = mod;
|
||||
|
||||
public string FolderName { get; set; } = "";
|
||||
public bool Enabled { get; set; }
|
||||
public int Priority { get; set; }
|
||||
public Dictionary< string, int > Conf { get; set; }
|
||||
public Dictionary< string, int > Conf { get; set; } = new();
|
||||
|
||||
[JsonIgnore]
|
||||
public ResourceMod Mod { get; set; }
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@ namespace Penumbra.Models
|
|||
public class ModMeta
|
||||
{
|
||||
public uint FileVersion { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Author { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string Name { get; set; } = "Mod";
|
||||
public string Author { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
|
||||
public string Version { get; set; }
|
||||
public string Version { get; set; } = "";
|
||||
|
||||
public string Website { get; set; }
|
||||
public string Website { get; set; } = "";
|
||||
|
||||
public List< string > ChangedItems { get; set; } = new();
|
||||
|
||||
|
|
@ -26,14 +26,12 @@ namespace Penumbra.Models
|
|||
[JsonIgnore]
|
||||
public bool HasGroupWithConfig { get; set; } = false;
|
||||
|
||||
public static ModMeta LoadFromFile( string filePath )
|
||||
public static ModMeta? LoadFromFile( string filePath )
|
||||
{
|
||||
try
|
||||
{
|
||||
var meta = JsonConvert.DeserializeObject< ModMeta >( File.ReadAllText( filePath ) );
|
||||
meta.HasGroupWithConfig =
|
||||
meta.Groups != null
|
||||
&& meta.Groups.Count > 0
|
||||
meta.HasGroupWithConfig = meta.Groups.Count > 0
|
||||
&& meta.Groups.Values.Any( G => G.SelectionType == SelectType.Multi || G.Options.Count > 1 );
|
||||
|
||||
return meta;
|
||||
|
|
|
|||
|
|
@ -12,11 +12,12 @@ namespace Penumbra.Mods
|
|||
{
|
||||
private readonly DirectoryInfo _basePath;
|
||||
|
||||
public List< ModInfo > ModSettings { get; set; }
|
||||
public ResourceMod[] EnabledMods { get; set; }
|
||||
public List< ModInfo >? ModSettings { get; set; }
|
||||
public ResourceMod[]? EnabledMods { get; set; }
|
||||
|
||||
|
||||
public ModCollection( DirectoryInfo basePath ) => _basePath = basePath;
|
||||
public ModCollection( DirectoryInfo basePath )
|
||||
=> _basePath = basePath;
|
||||
|
||||
public void Load( bool invertOrder = false )
|
||||
{
|
||||
|
|
@ -61,20 +62,10 @@ namespace Penumbra.Mods
|
|||
continue;
|
||||
}
|
||||
|
||||
var meta = ModMeta.LoadFromFile( metaFile.FullName );
|
||||
if( meta == null )
|
||||
{
|
||||
PluginLog.LogError( "mod meta is invalid for resource mod: {ResourceModFile}", metaFile.FullName );
|
||||
continue;
|
||||
}
|
||||
var meta = ModMeta.LoadFromFile( metaFile.FullName ) ?? new ModMeta();
|
||||
|
||||
var mod = new ResourceMod
|
||||
{
|
||||
Meta = meta,
|
||||
ModBasePath = modDir
|
||||
};
|
||||
|
||||
var modEntry = FindOrCreateModSettings( mod );
|
||||
var mod = new ResourceMod( meta, modDir );
|
||||
FindOrCreateModSettings( mod );
|
||||
foundMods.Add( modDir.Name );
|
||||
mod.RefreshModFiles();
|
||||
}
|
||||
|
|
@ -142,9 +133,9 @@ namespace Penumbra.Mods
|
|||
}
|
||||
|
||||
|
||||
public ModInfo FindModSettings( string name )
|
||||
public ModInfo? FindModSettings( string name )
|
||||
{
|
||||
var settings = ModSettings.FirstOrDefault(
|
||||
var settings = ModSettings?.FirstOrDefault(
|
||||
x => string.Equals( x.FolderName, name, StringComparison.InvariantCultureIgnoreCase )
|
||||
);
|
||||
#if DEBUG
|
||||
|
|
@ -155,18 +146,17 @@ namespace Penumbra.Mods
|
|||
|
||||
public ModInfo AddModSettings( ResourceMod mod )
|
||||
{
|
||||
var entry = new ModInfo
|
||||
var entry = new ModInfo(mod)
|
||||
{
|
||||
Priority = ModSettings.Count,
|
||||
Priority = ModSettings?.Count ?? 0,
|
||||
FolderName = mod.ModBasePath.Name,
|
||||
Enabled = true,
|
||||
Mod = mod
|
||||
};
|
||||
|
||||
#if DEBUG
|
||||
PluginLog.Information( "creating mod settings {ModName}", entry.FolderName );
|
||||
#endif
|
||||
|
||||
ModSettings ??= new List< ModInfo >();
|
||||
ModSettings.Add( entry );
|
||||
return entry;
|
||||
}
|
||||
|
|
@ -174,19 +164,20 @@ namespace Penumbra.Mods
|
|||
public ModInfo FindOrCreateModSettings( ResourceMod mod )
|
||||
{
|
||||
var settings = FindModSettings( mod.ModBasePath.Name );
|
||||
if( settings != null )
|
||||
if( settings == null )
|
||||
{
|
||||
settings.Mod = mod;
|
||||
return settings;
|
||||
return AddModSettings( mod );
|
||||
}
|
||||
|
||||
return AddModSettings( mod );
|
||||
settings.Mod = mod;
|
||||
return settings;
|
||||
|
||||
}
|
||||
|
||||
public IEnumerable< ModInfo > GetOrderedAndEnabledModSettings( bool invertOrder = false )
|
||||
{
|
||||
var query = ModSettings
|
||||
.Where( x => x.Enabled );
|
||||
var query = ModSettings?
|
||||
.Where( x => x.Enabled ) ?? Enumerable.Empty<ModInfo>();
|
||||
|
||||
if( !invertOrder )
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,11 +13,11 @@ namespace Penumbra.Mods
|
|||
public readonly Dictionary< string, FileInfo > ResolvedFiles = new();
|
||||
public readonly Dictionary< string, string > SwappedFiles = new();
|
||||
|
||||
public ModCollection Mods { get; set; }
|
||||
public ModCollection? Mods { get; set; }
|
||||
private DirectoryInfo? _basePath;
|
||||
|
||||
private DirectoryInfo _basePath;
|
||||
|
||||
public ModManager( Plugin plugin ) => _plugin = plugin;
|
||||
public ModManager( Plugin plugin )
|
||||
=> _plugin = plugin;
|
||||
|
||||
public void DiscoverMods()
|
||||
{
|
||||
|
|
@ -104,31 +104,26 @@ namespace Penumbra.Mods
|
|||
ResolvedFiles.Clear();
|
||||
SwappedFiles.Clear();
|
||||
|
||||
if( Mods == null )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var registeredFiles = new Dictionary< string, string >();
|
||||
|
||||
foreach( var (mod, settings) in Mods.GetOrderedAndEnabledModListWithSettings( _plugin.Configuration.InvertModListOrder ) )
|
||||
foreach( var (mod, settings) in Mods.GetOrderedAndEnabledModListWithSettings( _plugin!.Configuration!.InvertModListOrder ) )
|
||||
{
|
||||
mod.FileConflicts?.Clear();
|
||||
if( settings.Conf == null )
|
||||
{
|
||||
settings.Conf = new Dictionary< string, int >();
|
||||
Mods.Save();
|
||||
}
|
||||
mod.FileConflicts.Clear();
|
||||
|
||||
ProcessModFiles( registeredFiles, mod, settings );
|
||||
ProcessSwappedFiles( registeredFiles, mod, settings );
|
||||
}
|
||||
|
||||
_plugin.GameUtils.ReloadPlayerResources();
|
||||
_plugin!.GameUtils!.ReloadPlayerResources();
|
||||
}
|
||||
|
||||
private void ProcessSwappedFiles( Dictionary< string, string > registeredFiles, ResourceMod mod, ModInfo settings )
|
||||
{
|
||||
if( mod?.Meta?.FileSwaps == null )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach( var swap in mod.Meta.FileSwaps )
|
||||
{
|
||||
// just assume people put not fucked paths in here lol
|
||||
|
|
@ -162,7 +157,7 @@ namespace Penumbra.Mods
|
|||
&& settings.Conf[ group.GroupName ] >= group.Options.Count )
|
||||
{
|
||||
settings.Conf[ group.GroupName ] = 0;
|
||||
Mods.Save();
|
||||
Mods!.Save();
|
||||
setting = 0;
|
||||
}
|
||||
|
||||
|
|
@ -240,13 +235,13 @@ namespace Penumbra.Mods
|
|||
|
||||
public void ChangeModPriority( ModInfo info, bool up = false )
|
||||
{
|
||||
Mods.ReorderMod( info, up );
|
||||
Mods!.ReorderMod( info, up );
|
||||
CalculateEffectiveFileList();
|
||||
}
|
||||
|
||||
public void DeleteMod( ResourceMod mod )
|
||||
public void DeleteMod( ResourceMod? mod )
|
||||
{
|
||||
if( mod?.ModBasePath?.Exists ?? false )
|
||||
if( mod?.ModBasePath.Exists ?? false )
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -261,7 +256,7 @@ namespace Penumbra.Mods
|
|||
DiscoverMods();
|
||||
}
|
||||
|
||||
public FileInfo GetCandidateForGameFile( string gameResourcePath )
|
||||
public FileInfo? GetCandidateForGameFile( string gameResourcePath )
|
||||
{
|
||||
var val = ResolvedFiles.TryGetValue( gameResourcePath, out var candidate );
|
||||
if( !val )
|
||||
|
|
@ -277,10 +272,10 @@ namespace Penumbra.Mods
|
|||
return candidate;
|
||||
}
|
||||
|
||||
public string GetSwappedFilePath( string gameResourcePath )
|
||||
public string? GetSwappedFilePath( string gameResourcePath )
|
||||
=> SwappedFiles.TryGetValue( gameResourcePath, out var swappedPath ) ? swappedPath : null;
|
||||
|
||||
public string ResolveSwappedOrReplacementFilePath( string gameResourcePath )
|
||||
public string? ResolveSwappedOrReplacementFilePath( string gameResourcePath )
|
||||
{
|
||||
gameResourcePath = gameResourcePath.ToLowerInvariant();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Dalamud.Plugin;
|
||||
using Penumbra.Models;
|
||||
|
||||
|
|
@ -7,6 +8,12 @@ namespace Penumbra.Mods
|
|||
{
|
||||
public class ResourceMod
|
||||
{
|
||||
public ResourceMod( ModMeta meta, DirectoryInfo dir )
|
||||
{
|
||||
Meta = meta;
|
||||
ModBasePath = dir;
|
||||
}
|
||||
|
||||
public ModMeta Meta { get; set; }
|
||||
|
||||
public DirectoryInfo ModBasePath { get; set; }
|
||||
|
|
@ -17,26 +24,13 @@ namespace Penumbra.Mods
|
|||
|
||||
public void RefreshModFiles()
|
||||
{
|
||||
if( ModBasePath == null )
|
||||
{
|
||||
PluginLog.LogError( "no basepath has been set on {ResourceModName}", Meta.Name );
|
||||
return;
|
||||
}
|
||||
|
||||
ModFiles.Clear();
|
||||
// we don't care about any _files_ in the root dir, but any folders should be a game folder/file combo
|
||||
foreach( var dir in ModBasePath.EnumerateDirectories() )
|
||||
foreach( var file in ModBasePath.EnumerateDirectories()
|
||||
.SelectMany( dir => dir.EnumerateFiles( "*.*", SearchOption.AllDirectories ) ) )
|
||||
{
|
||||
foreach( var file in dir.EnumerateFiles( "*.*", SearchOption.AllDirectories ) )
|
||||
{
|
||||
ModFiles.Add( file );
|
||||
}
|
||||
ModFiles.Add( file );
|
||||
}
|
||||
|
||||
// Only add if not in a sub-folder, otherwise it was already added.
|
||||
//foreach( var pair in Meta.Groups.FileToGameAndGroup )
|
||||
// if (pair.Key.IndexOfAny(new[]{'/', '\\'}) < 0)
|
||||
// ModFiles.Add( new FileInfo(Path.Combine(ModBasePath.FullName, pair.Key)) );
|
||||
}
|
||||
|
||||
public void AddConflict( string modName, string path )
|
||||
|
|
|
|||
|
|
@ -13,24 +13,25 @@ namespace Penumbra
|
|||
{
|
||||
public class Plugin : IDalamudPlugin
|
||||
{
|
||||
public string Name => "Penumbra";
|
||||
public string Name { get; }
|
||||
public string PluginDebugTitleStr { get; }
|
||||
|
||||
public Plugin()
|
||||
{
|
||||
Name = "Penumbra";
|
||||
PluginDebugTitleStr = $"{Name} - Debug Build";
|
||||
}
|
||||
|
||||
private const string CommandName = "/penumbra";
|
||||
|
||||
public DalamudPluginInterface PluginInterface { get; set; }
|
||||
public DalamudPluginInterface? PluginInterface { get; set; }
|
||||
public Configuration? Configuration { get; set; }
|
||||
public ResourceLoader? ResourceLoader { get; set; }
|
||||
public SettingsInterface? SettingsInterface { get; set; }
|
||||
public GameUtils? GameUtils { get; set; }
|
||||
public SoundShit? SoundShit { get; set; }
|
||||
|
||||
public Configuration Configuration { get; set; }
|
||||
|
||||
public ResourceLoader ResourceLoader { get; set; }
|
||||
|
||||
public SettingsInterface SettingsInterface { get; set; }
|
||||
|
||||
public GameUtils GameUtils { get; set; }
|
||||
public SoundShit SoundShit { get; set; }
|
||||
|
||||
public string PluginDebugTitleStr { get; private set; }
|
||||
|
||||
private WebServer _webServer;
|
||||
private WebServer? _webServer;
|
||||
|
||||
public void Initialize( DalamudPluginInterface pluginInterface )
|
||||
{
|
||||
|
|
@ -61,8 +62,6 @@ namespace Penumbra
|
|||
|
||||
PluginInterface.UiBuilder.OnBuildUi += SettingsInterface.Draw;
|
||||
|
||||
PluginDebugTitleStr = $"{Name} - Debug Build";
|
||||
|
||||
if( Configuration.EnableHttpApi )
|
||||
{
|
||||
CreateWebServer();
|
||||
|
|
@ -97,12 +96,12 @@ namespace Penumbra
|
|||
{
|
||||
// ModManager?.Dispose();
|
||||
|
||||
PluginInterface.UiBuilder.OnBuildUi -= SettingsInterface.Draw;
|
||||
PluginInterface!.UiBuilder.OnBuildUi -= SettingsInterface!.Draw;
|
||||
|
||||
PluginInterface.CommandManager.RemoveHandler( CommandName );
|
||||
PluginInterface.Dispose();
|
||||
|
||||
ResourceLoader.Dispose();
|
||||
ResourceLoader?.Dispose();
|
||||
|
||||
ShutdownWebServer();
|
||||
}
|
||||
|
|
@ -117,8 +116,8 @@ namespace Penumbra
|
|||
case "reload":
|
||||
{
|
||||
Service< ModManager >.Get().DiscoverMods();
|
||||
PluginInterface.Framework.Gui.Chat.Print(
|
||||
$"Reloaded Penumbra mods. You have {Service< ModManager >.Get().Mods.ModSettings.Count} mods, {Service< ModManager >.Get().Mods.EnabledMods.Length} of which are enabled."
|
||||
PluginInterface!.Framework.Gui.Chat.Print(
|
||||
$"Reloaded Penumbra mods. You have {Service< ModManager >.Get()?.Mods?.ModSettings?.Count ?? 0} mods, {Service< ModManager >.Get()?.Mods?.EnabledMods?.Length ?? 0} of which are enabled."
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
|
@ -126,11 +125,11 @@ namespace Penumbra
|
|||
{
|
||||
if( args.Length > 1 )
|
||||
{
|
||||
RefreshActors.RedrawSpecific( PluginInterface.ClientState.Actors, string.Join( " ", args.Skip( 1 ) ) );
|
||||
RefreshActors.RedrawSpecific( PluginInterface!.ClientState.Actors, string.Join( " ", args.Skip( 1 ) ) );
|
||||
}
|
||||
else
|
||||
{
|
||||
RefreshActors.RedrawAll( PluginInterface.ClientState.Actors );
|
||||
RefreshActors.RedrawAll( PluginInterface!.ClientState.Actors );
|
||||
}
|
||||
|
||||
break;
|
||||
|
|
@ -140,7 +139,7 @@ namespace Penumbra
|
|||
return;
|
||||
}
|
||||
|
||||
SettingsInterface.FlipVisibility();
|
||||
SettingsInterface!.FlipVisibility();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ namespace Penumbra.UI
|
|||
public ManageModsButton( SettingsInterface ui )
|
||||
{
|
||||
_base = ui;
|
||||
_condition = ui._plugin.PluginInterface.ClientState.Condition;
|
||||
_condition = ui._plugin!.PluginInterface!.ClientState.Condition;
|
||||
}
|
||||
|
||||
public void Draw()
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ namespace Penumbra.UI
|
|||
_menu.InstalledTab.Selector.ResetModNamesLower();
|
||||
_menu.InstalledTab.Selector.ClearSelection();
|
||||
// create the directory if it doesn't exist
|
||||
Directory.CreateDirectory( _plugin.Configuration.CurrentCollection );
|
||||
Directory.CreateDirectory( _plugin!.Configuration!.CurrentCollection );
|
||||
|
||||
var modManager = Service< ModManager >.Get();
|
||||
modManager.DiscoverMods( _plugin.Configuration.CurrentCollection );
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ namespace Penumbra.UI
|
|||
_browserTab.Draw();
|
||||
InstalledTab.Draw();
|
||||
|
||||
if( _base._plugin.Configuration.ShowAdvanced )
|
||||
if( _base._plugin!.Configuration!.ShowAdvanced )
|
||||
{
|
||||
EffectiveTab.Draw();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@ namespace Penumbra.UI
|
|||
private const float TextSizePadding = 5f;
|
||||
|
||||
private ModManager _mods => Service< ModManager >.Get();
|
||||
private (string, string)[] _fileList;
|
||||
private float _maxGamePath;
|
||||
private (string, string)[]? _fileList;
|
||||
private float _maxGamePath;
|
||||
|
||||
public TabEffective( SettingsInterface ui )
|
||||
{
|
||||
RebuildFileList( ui._plugin.Configuration.ShowAdvanced );
|
||||
RebuildFileList( ui._plugin!.Configuration!.ShowAdvanced );
|
||||
}
|
||||
|
||||
public void RebuildFileList( bool advanced )
|
||||
|
|
@ -54,7 +54,7 @@ namespace Penumbra.UI
|
|||
|
||||
if( ImGui.ListBoxHeader( "##effective_files", AutoFillSize ) )
|
||||
{
|
||||
foreach( var file in _fileList )
|
||||
foreach( var file in _fileList ?? Enumerable.Empty<(string, string)>() )
|
||||
{
|
||||
DrawFileLine( file );
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ namespace Penumbra.UI
|
|||
|
||||
private bool _isImportRunning = false;
|
||||
private bool _hasError = false;
|
||||
private TexToolsImport _texToolsImport = null!;
|
||||
private TexToolsImport? _texToolsImport;
|
||||
private readonly SettingsInterface _base;
|
||||
|
||||
public TabImport( SettingsInterface ui ) => _base = ui;
|
||||
|
|
@ -59,7 +59,7 @@ namespace Penumbra.UI
|
|||
|
||||
try
|
||||
{
|
||||
_texToolsImport = new TexToolsImport( new DirectoryInfo( _base._plugin.Configuration.CurrentCollection ) );
|
||||
_texToolsImport = new TexToolsImport( new DirectoryInfo( _base._plugin!.Configuration!.CurrentCollection ) );
|
||||
_texToolsImport.ImportModPack( new FileInfo( fileName ) );
|
||||
|
||||
PluginLog.Log( $"-> {fileName} OK!" );
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ namespace Penumbra.UI
|
|||
// Remove the entry at idx from the list if the new string is empty, otherwise replace it.
|
||||
public static void RemoveOrChange( this List< string > list, string newString, int idx )
|
||||
{
|
||||
if( newString?.Length == 0 )
|
||||
if( newString.Length == 0 )
|
||||
{
|
||||
list.RemoveAt( idx );
|
||||
}
|
||||
|
|
@ -53,29 +53,31 @@ namespace Penumbra.UI
|
|||
private const uint ColorYellow = 0xFF00C8C8;
|
||||
private const uint ColorRed = 0xFF0000C8;
|
||||
|
||||
private bool _editMode = false;
|
||||
private int _selectedGroupIndex = 0;
|
||||
private InstallerInfo? _selectedGroup = null;
|
||||
private int _selectedOptionIndex = 0;
|
||||
private Option? _selectedOption = null;
|
||||
private (string label, string name)[] _changedItemsList = null;
|
||||
private float? _fileSwapOffset = null;
|
||||
private string _currentGamePaths = "";
|
||||
private bool _editMode = false;
|
||||
private int _selectedGroupIndex = 0;
|
||||
private InstallerInfo? _selectedGroup = null;
|
||||
private int _selectedOptionIndex = 0;
|
||||
private Option? _selectedOption = null;
|
||||
private (string label, string name)[]? _changedItemsList = null;
|
||||
private float? _fileSwapOffset = null;
|
||||
private string _currentGamePaths = "";
|
||||
|
||||
private (string name, bool selected, uint color, string relName)[] _fullFilenameList = null;
|
||||
private (string name, bool selected, uint color, string relName)[]? _fullFilenameList = null;
|
||||
|
||||
private readonly Selector _selector;
|
||||
private readonly SettingsInterface _base;
|
||||
|
||||
private void SelectGroup( int idx )
|
||||
{
|
||||
// Not using the properties here because we need it to be not null forgiving in this case.
|
||||
var numGroups = _selector.Mod()?.Mod.Meta.Groups.Count ?? 0;
|
||||
_selectedGroupIndex = idx;
|
||||
if( _selectedGroupIndex >= Meta?.Groups?.Count )
|
||||
if( _selectedGroupIndex >= numGroups)
|
||||
{
|
||||
_selectedGroupIndex = 0;
|
||||
}
|
||||
|
||||
if( Meta?.Groups?.Count > 0 )
|
||||
if( numGroups > 0 )
|
||||
{
|
||||
_selectedGroup = Meta.Groups.ElementAt( _selectedGroupIndex ).Value;
|
||||
}
|
||||
|
|
@ -123,20 +125,21 @@ namespace Penumbra.UI
|
|||
ResetState();
|
||||
}
|
||||
|
||||
private ModInfo Mod => _selector.Mod();
|
||||
private ModMeta Meta => Mod?.Mod?.Meta;
|
||||
// This is only drawn when we have a mod selected, so we can forgive nulls.
|
||||
private ModInfo Mod => _selector.Mod()!;
|
||||
private ModMeta Meta => Mod.Mod.Meta;
|
||||
|
||||
private void Save()
|
||||
{
|
||||
var modManager = Service< ModManager >.Get();
|
||||
modManager.Mods.Save();
|
||||
modManager.Mods?.Save();
|
||||
modManager.CalculateEffectiveFileList();
|
||||
_base._menu.EffectiveTab.RebuildFileList( _base._plugin.Configuration.ShowAdvanced );
|
||||
_base._menu.EffectiveTab.RebuildFileList( _base._plugin!.Configuration!.ShowAdvanced );
|
||||
}
|
||||
|
||||
private void DrawAboutTab()
|
||||
{
|
||||
if( !_editMode && ( Meta.Description?.Length ?? 0 ) == 0 )
|
||||
if( !_editMode && Meta.Description.Length == 0 )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -174,13 +177,11 @@ namespace Penumbra.UI
|
|||
|
||||
private void DrawChangedItemsTab()
|
||||
{
|
||||
if( !_editMode && ( Meta.ChangedItems?.Count ?? 0 ) == 0 )
|
||||
if( !_editMode && Meta.ChangedItems.Count == 0 )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Meta.ChangedItems ??= new List< string >();
|
||||
|
||||
var flags = _editMode
|
||||
? ImGuiInputTextFlags.EnterReturnsTrue
|
||||
: ImGuiInputTextFlags.ReadOnly;
|
||||
|
|
@ -210,15 +211,7 @@ namespace Penumbra.UI
|
|||
{
|
||||
if( newItem.Length > 0 )
|
||||
{
|
||||
if( Meta.ChangedItems == null )
|
||||
{
|
||||
Meta.ChangedItems = new List< string >() { newItem };
|
||||
}
|
||||
else
|
||||
{
|
||||
Meta.ChangedItems.Add( newItem );
|
||||
}
|
||||
|
||||
Meta.ChangedItems.Add( newItem );
|
||||
_selector.SaveCurrentMod();
|
||||
}
|
||||
}
|
||||
|
|
@ -317,7 +310,7 @@ namespace Penumbra.UI
|
|||
var len = Mod.Mod.ModBasePath.FullName.Length;
|
||||
_fullFilenameList = Mod.Mod.ModFiles.Select( F => ( F.FullName, false, ColorGreen, "" ) ).ToArray();
|
||||
|
||||
if( Meta.Groups?.Count == 0 )
|
||||
if( Meta.Groups.Count == 0 )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
@ -369,7 +362,7 @@ namespace Penumbra.UI
|
|||
if( ImGui.ListBoxHeader( LabelFileListHeader, AutoFillSize ) )
|
||||
{
|
||||
UpdateFilenameList();
|
||||
foreach( var file in _fullFilenameList )
|
||||
foreach( var file in _fullFilenameList! )
|
||||
{
|
||||
ImGui.PushStyleColor( ImGuiCol.Text, file.color );
|
||||
ImGui.Selectable( file.name );
|
||||
|
|
@ -405,7 +398,7 @@ namespace Penumbra.UI
|
|||
var changed = false;
|
||||
for( var i = 0; i < Mod.Mod.ModFiles.Count; ++i )
|
||||
{
|
||||
if( !_fullFilenameList[ i ].selected )
|
||||
if( !_fullFilenameList![ i ].selected )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
|
@ -503,7 +496,7 @@ namespace Penumbra.UI
|
|||
{
|
||||
void Selectable( uint colorNormal, uint colorReplace )
|
||||
{
|
||||
var loc = _fullFilenameList[ idx ].color;
|
||||
var loc = _fullFilenameList![ idx ].color;
|
||||
if( loc == colorNormal )
|
||||
{
|
||||
loc = colorReplace;
|
||||
|
|
@ -521,7 +514,7 @@ namespace Penumbra.UI
|
|||
return;
|
||||
}
|
||||
|
||||
var fileName = _fullFilenameList[ idx ].relName;
|
||||
var fileName = _fullFilenameList![ idx ].relName;
|
||||
if( ( ( Option )_selectedOption ).OptionFiles.TryGetValue( fileName, out var gamePaths ) )
|
||||
{
|
||||
Selectable( 0, ColorGreen );
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ namespace Penumbra.UI
|
|||
private bool DrawEditGroupSelector()
|
||||
{
|
||||
ImGui.SetNextItemWidth( OptionSelectionWidth );
|
||||
if( Meta.Groups.Count == 0 )
|
||||
if( Meta!.Groups.Count == 0 )
|
||||
{
|
||||
ImGui.Combo( LabelGroupSelect, ref _selectedGroupIndex, TextNoOptionAvailable, 1 );
|
||||
return false;
|
||||
|
|
@ -63,7 +63,7 @@ namespace Penumbra.UI
|
|||
return false;
|
||||
}
|
||||
|
||||
var group = ( InstallerInfo )_selectedGroup;
|
||||
var group = ( InstallerInfo )_selectedGroup!;
|
||||
if( ImGui.Combo( LabelOptionSelect, ref _selectedOptionIndex, group.Options.Select( O => O.OptionName ).ToArray(),
|
||||
group.Options.Count ) )
|
||||
{
|
||||
|
|
@ -86,7 +86,7 @@ namespace Penumbra.UI
|
|||
ImGui.SetNextItemWidth( -1 );
|
||||
if( ImGui.ListBoxHeader( LabelFileListHeader, AutoFillSize - new Vector2( 0, 1.5f * ImGui.GetTextLineHeight() ) ) )
|
||||
{
|
||||
for( var i = 0; i < Mod.Mod.ModFiles.Count; ++i )
|
||||
for( var i = 0; i < Mod!.Mod.ModFiles.Count; ++i )
|
||||
{
|
||||
DrawFileAndGamePaths( i );
|
||||
}
|
||||
|
|
@ -107,9 +107,9 @@ namespace Penumbra.UI
|
|||
{
|
||||
var groupName = group.GroupName;
|
||||
if( ImGuiCustom.BeginFramedGroupEdit( ref groupName )
|
||||
&& groupName != group.GroupName && !Meta.Groups.ContainsKey( groupName ) )
|
||||
&& groupName != group.GroupName && !Meta!.Groups.ContainsKey( groupName ) )
|
||||
{
|
||||
var oldConf = Mod.Conf[ group.GroupName ];
|
||||
var oldConf = Mod!.Conf[ group.GroupName ];
|
||||
Meta.Groups.Remove( group.GroupName );
|
||||
Mod.Conf.Remove( group.GroupName );
|
||||
if( groupName.Length > 0 )
|
||||
|
|
@ -142,7 +142,7 @@ namespace Penumbra.UI
|
|||
private void DrawMultiSelectorEdit( InstallerInfo group )
|
||||
{
|
||||
var nameBoxStart = CheckMarkSize;
|
||||
var flag = Mod.Conf[ group.GroupName ];
|
||||
var flag = Mod!.Conf[ group.GroupName ];
|
||||
|
||||
var modChanged = DrawMultiSelectorEditBegin( group );
|
||||
|
||||
|
|
@ -194,9 +194,9 @@ namespace Penumbra.UI
|
|||
{
|
||||
var groupName = group.GroupName;
|
||||
if( ImGui.InputText( $"##{groupName}_add", ref groupName, 64, ImGuiInputTextFlags.EnterReturnsTrue )
|
||||
&& !Meta.Groups.ContainsKey( groupName ) )
|
||||
&& !Meta!.Groups.ContainsKey( groupName ) )
|
||||
{
|
||||
var oldConf = Mod.Conf[ group.GroupName ];
|
||||
var oldConf = Mod!.Conf[ group.GroupName ];
|
||||
if( groupName != group.GroupName )
|
||||
{
|
||||
Meta.Groups.Remove( group.GroupName );
|
||||
|
|
@ -218,7 +218,7 @@ namespace Penumbra.UI
|
|||
|
||||
private float DrawSingleSelectorEdit( InstallerInfo group )
|
||||
{
|
||||
var code = Mod.Conf[ group.GroupName ];
|
||||
var code = Mod!.Conf[ group.GroupName ];
|
||||
var selectionChanged = false;
|
||||
var modChanged = false;
|
||||
if( ImGuiCustom.RenameableCombo( $"##{group.GroupName}", ref code, out var newName,
|
||||
|
|
@ -283,7 +283,7 @@ namespace Penumbra.UI
|
|||
|
||||
private void AddNewGroup( string newGroup, SelectType selectType )
|
||||
{
|
||||
if( Meta.Groups.ContainsKey( newGroup ) || newGroup.Length <= 0 )
|
||||
if( Meta!.Groups.ContainsKey( newGroup ) || newGroup.Length <= 0 )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,15 +48,15 @@ namespace Penumbra.UI
|
|||
_base = ui;
|
||||
_selector = s;
|
||||
Details = new PluginDetails( _base, _selector );
|
||||
_currentWebsite = Meta?.Website;
|
||||
_currentWebsite = Meta?.Website ?? "";
|
||||
}
|
||||
|
||||
private ModInfo Mod => _selector.Mod();
|
||||
private ModMeta Meta => Mod?.Mod.Meta;
|
||||
private ModInfo? Mod => _selector.Mod();
|
||||
private ModMeta? Meta => Mod?.Mod.Meta;
|
||||
|
||||
private void DrawName()
|
||||
{
|
||||
var name = Meta.Name;
|
||||
var name = Meta!.Name;
|
||||
if( ImGuiCustom.InputOrText( _editMode, LabelEditName, ref name, 64 )
|
||||
&& name.Length > 0 && name != Meta.Name )
|
||||
{
|
||||
|
|
@ -74,11 +74,11 @@ namespace Penumbra.UI
|
|||
|
||||
ImGui.PushStyleVar( ImGuiStyleVar.ItemSpacing, ZeroVector );
|
||||
ImGui.SameLine();
|
||||
var version = Meta.Version ?? "";
|
||||
var version = Meta!.Version;
|
||||
if( ImGuiCustom.ResizingTextInput( LabelEditVersion, ref version, 16 )
|
||||
&& version != Meta.Version )
|
||||
{
|
||||
Meta.Version = version.Length > 0 ? version : null;
|
||||
Meta.Version = version;
|
||||
_selector.SaveCurrentMod();
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +87,7 @@ namespace Penumbra.UI
|
|||
ImGui.PopStyleVar();
|
||||
ImGui.EndGroup();
|
||||
}
|
||||
else if( ( Meta.Version?.Length ?? 0 ) > 0 )
|
||||
else if( Meta!.Version.Length > 0 )
|
||||
{
|
||||
ImGui.Text( $"(Version {Meta.Version})" );
|
||||
}
|
||||
|
|
@ -99,11 +99,11 @@ namespace Penumbra.UI
|
|||
ImGui.TextColored( GreyColor, "by" );
|
||||
|
||||
ImGui.SameLine();
|
||||
var author = Meta.Author ?? "";
|
||||
var author = Meta!.Author;
|
||||
if( ImGuiCustom.InputOrText( _editMode, LabelEditAuthor, ref author, 64 )
|
||||
&& author != Meta.Author )
|
||||
{
|
||||
Meta.Author = author.Length > 0 ? author : null;
|
||||
Meta.Author = author;
|
||||
_selector.SaveCurrentMod();
|
||||
}
|
||||
|
||||
|
|
@ -117,15 +117,15 @@ namespace Penumbra.UI
|
|||
{
|
||||
ImGui.TextColored( GreyColor, "from" );
|
||||
ImGui.SameLine();
|
||||
var website = Meta.Website ?? "";
|
||||
var website = Meta!.Website;
|
||||
if( ImGuiCustom.ResizingTextInput( LabelEditWebsite, ref website, 512 )
|
||||
&& website != Meta.Website )
|
||||
{
|
||||
Meta.Website = website.Length > 0 ? website : null;
|
||||
Meta.Website = website;
|
||||
_selector.SaveCurrentMod();
|
||||
}
|
||||
}
|
||||
else if( ( Meta.Website?.Length ?? 0 ) > 0 )
|
||||
else if( Meta!.Website.Length > 0 )
|
||||
{
|
||||
if( _currentWebsite != Meta.Website )
|
||||
{
|
||||
|
|
@ -181,14 +181,14 @@ namespace Penumbra.UI
|
|||
|
||||
private void DrawEnabledMark()
|
||||
{
|
||||
var enabled = Mod.Enabled;
|
||||
var enabled = Mod!.Enabled;
|
||||
if( ImGui.Checkbox( LabelModEnabled, ref enabled ) )
|
||||
{
|
||||
Mod.Enabled = enabled;
|
||||
var modManager = Service< ModManager >.Get();
|
||||
modManager.Mods.Save();
|
||||
modManager.Mods!.Save();
|
||||
modManager.CalculateEffectiveFileList();
|
||||
_base._menu.EffectiveTab.RebuildFileList( _base._plugin.Configuration.ShowAdvanced );
|
||||
_base._menu.EffectiveTab.RebuildFileList( _base._plugin!.Configuration!.ShowAdvanced );
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -201,7 +201,7 @@ namespace Penumbra.UI
|
|||
{
|
||||
if( ImGui.Button( ButtonOpenModFolder ) )
|
||||
{
|
||||
Process.Start( Mod.Mod.ModBasePath.FullName );
|
||||
Process.Start( Mod!.Mod.ModBasePath.FullName );
|
||||
}
|
||||
|
||||
if( ImGui.IsItemHovered() )
|
||||
|
|
@ -240,11 +240,11 @@ namespace Penumbra.UI
|
|||
{
|
||||
if( ImGui.Button( ButtonDeduplicate ) )
|
||||
{
|
||||
new Deduplicator( Mod.Mod.ModBasePath, Meta ).Run();
|
||||
new Deduplicator( Mod!.Mod.ModBasePath, Meta! ).Run();
|
||||
_selector.SaveCurrentMod();
|
||||
Mod.Mod.RefreshModFiles();
|
||||
Service< ModManager >.Get().CalculateEffectiveFileList();
|
||||
_base._menu.EffectiveTab.RebuildFileList( _base._plugin.Configuration.ShowAdvanced );
|
||||
_base._menu.EffectiveTab.RebuildFileList( _base._plugin!.Configuration!.ShowAdvanced );
|
||||
}
|
||||
|
||||
if( ImGui.IsItemHovered() )
|
||||
|
|
@ -285,7 +285,7 @@ namespace Penumbra.UI
|
|||
ImGuiCustom.VerticalDistance( HeaderLineDistance );
|
||||
|
||||
DrawEnabledMark();
|
||||
if( _base._plugin.Configuration.ShowAdvanced )
|
||||
if( _base._plugin!.Configuration!.ShowAdvanced )
|
||||
{
|
||||
ImGui.SameLine();
|
||||
DrawEditableMark();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Linq;
|
|||
using System.Numerics;
|
||||
using Dalamud.Interface;
|
||||
using ImGuiNET;
|
||||
using ImGuiScene;
|
||||
using Newtonsoft.Json;
|
||||
using Penumbra.Models;
|
||||
using Penumbra.Mods;
|
||||
|
|
@ -32,13 +33,13 @@ namespace Penumbra.UI
|
|||
private static readonly string ArrowDownString = FontAwesomeIcon.ArrowDown.ToIconString();
|
||||
|
||||
private readonly SettingsInterface _base;
|
||||
private ModCollection Mods => Service< ModManager >.Get().Mods;
|
||||
private ModCollection? Mods => Service< ModManager >.Get().Mods;
|
||||
|
||||
private ModInfo _mod;
|
||||
private int _index;
|
||||
private int? _deleteIndex;
|
||||
private string _modFilter = "";
|
||||
private string[] _modNamesLower;
|
||||
private ModInfo? _mod;
|
||||
private int _index;
|
||||
private int? _deleteIndex;
|
||||
private string _modFilter = "";
|
||||
private string[]? _modNamesLower;
|
||||
|
||||
|
||||
public Selector( SettingsInterface ui )
|
||||
|
|
@ -49,7 +50,8 @@ namespace Penumbra.UI
|
|||
|
||||
public void ResetModNamesLower()
|
||||
{
|
||||
_modNamesLower = Mods?.ModSettings?.Select( I => I.Mod.Meta.Name.ToLowerInvariant() ).ToArray() ?? new string[]{};
|
||||
_modNamesLower = Mods?.ModSettings?.Where(I => I.Mod != null)
|
||||
.Select( I => I.Mod!.Meta.Name.ToLowerInvariant() ).ToArray() ?? new string[]{};
|
||||
}
|
||||
|
||||
private void DrawPriorityChangeButton( string iconString, bool up, int unavailableWhen )
|
||||
|
|
@ -60,8 +62,8 @@ namespace Penumbra.UI
|
|||
if( ImGui.Button( iconString, SelectorButtonSizes ) )
|
||||
{
|
||||
SetSelection( _index );
|
||||
Service< ModManager >.Get().ChangeModPriority( _mod, up );
|
||||
_modNamesLower.Swap( _index, _index + ( up ? 1 : -1 ) );
|
||||
Service< ModManager >.Get().ChangeModPriority( _mod!, up );
|
||||
_modNamesLower!.Swap( _index, _index + ( up ? 1 : -1 ) );
|
||||
_index += up ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
|
@ -77,7 +79,7 @@ namespace Penumbra.UI
|
|||
if( ImGui.IsItemHovered() )
|
||||
{
|
||||
ImGui.SetTooltip(
|
||||
_base._plugin.Configuration.InvertModListOrder ^ up ? TooltipMoveDown : TooltipMoveUp
|
||||
_base._plugin!.Configuration!.InvertModListOrder ^ up ? TooltipMoveDown : TooltipMoveUp
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -139,7 +141,7 @@ namespace Penumbra.UI
|
|||
|
||||
DrawPriorityChangeButton( ArrowUpString, false, 0 );
|
||||
ImGui.SameLine();
|
||||
DrawPriorityChangeButton( ArrowDownString, true, Mods?.ModSettings.Count - 1 ?? 0 );
|
||||
DrawPriorityChangeButton( ArrowDownString, true, Mods?.ModSettings?.Count - 1 ?? 0 );
|
||||
ImGui.SameLine();
|
||||
DrawModTrashButton();
|
||||
ImGui.SameLine();
|
||||
|
|
@ -177,7 +179,7 @@ namespace Penumbra.UI
|
|||
if( ImGui.Button( ButtonYesDelete ) )
|
||||
{
|
||||
ImGui.CloseCurrentPopup();
|
||||
Service< ModManager >.Get().DeleteMod( _mod.Mod );
|
||||
Service< ModManager >.Get().DeleteMod( _mod?.Mod );
|
||||
ClearSelection();
|
||||
_base.ReloadMods();
|
||||
}
|
||||
|
|
@ -210,44 +212,47 @@ namespace Penumbra.UI
|
|||
// Inlay selector list
|
||||
ImGui.BeginChild( LabelSelectorList, new Vector2( SelectorPanelWidth, -ImGui.GetFrameHeightWithSpacing() ), true );
|
||||
|
||||
for( var modIndex = 0; modIndex < Mods.ModSettings.Count; modIndex++ )
|
||||
if( Mods.ModSettings != null )
|
||||
{
|
||||
var settings = Mods.ModSettings[ modIndex ];
|
||||
var modName = settings.Mod.Meta.Name;
|
||||
if( _modFilter.Length > 0 && !_modNamesLower[ modIndex ].Contains( _modFilter ) )
|
||||
for( var modIndex = 0; modIndex < Mods.ModSettings.Count; modIndex++ )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var settings = Mods.ModSettings[ modIndex ];
|
||||
var modName = settings.Mod.Meta.Name;
|
||||
if( _modFilter.Length > 0 && !_modNamesLower![ modIndex ].Contains( _modFilter ) )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var changedColour = false;
|
||||
if( !settings.Enabled )
|
||||
{
|
||||
ImGui.PushStyleColor( ImGuiCol.Text, DisabledModColor );
|
||||
changedColour = true;
|
||||
}
|
||||
else if( settings.Mod.FileConflicts.Any() )
|
||||
{
|
||||
ImGui.PushStyleColor( ImGuiCol.Text, ConflictingModColor );
|
||||
changedColour = true;
|
||||
}
|
||||
var changedColour = false;
|
||||
if( !settings.Enabled )
|
||||
{
|
||||
ImGui.PushStyleColor( ImGuiCol.Text, DisabledModColor );
|
||||
changedColour = true;
|
||||
}
|
||||
else if( settings.Mod.FileConflicts.Any() )
|
||||
{
|
||||
ImGui.PushStyleColor( ImGuiCol.Text, ConflictingModColor );
|
||||
changedColour = true;
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
var selected = ImGui.Selectable(
|
||||
$"id={modIndex} {modName}",
|
||||
modIndex == _index
|
||||
);
|
||||
var selected = ImGui.Selectable(
|
||||
$"id={modIndex} {modName}",
|
||||
modIndex == _index
|
||||
);
|
||||
#else
|
||||
var selected = ImGui.Selectable( modName, modIndex == _index );
|
||||
var selected = ImGui.Selectable( modName, modIndex == _index );
|
||||
#endif
|
||||
|
||||
if( changedColour )
|
||||
{
|
||||
ImGui.PopStyleColor();
|
||||
}
|
||||
if( changedColour )
|
||||
{
|
||||
ImGui.PopStyleColor();
|
||||
}
|
||||
|
||||
if( selected )
|
||||
{
|
||||
SetSelection( modIndex, settings );
|
||||
if( selected )
|
||||
{
|
||||
SetSelection( modIndex, settings );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -259,9 +264,9 @@ namespace Penumbra.UI
|
|||
DrawDeleteModal();
|
||||
}
|
||||
|
||||
public ModInfo Mod() => _mod;
|
||||
public ModInfo? Mod() => _mod;
|
||||
|
||||
private void SetSelection( int idx, ModInfo info )
|
||||
private void SetSelection( int idx, ModInfo? info )
|
||||
{
|
||||
_mod = info;
|
||||
if( idx != _index )
|
||||
|
|
@ -286,7 +291,7 @@ namespace Penumbra.UI
|
|||
}
|
||||
else
|
||||
{
|
||||
SetSelection( idx, Mods.ModSettings[ idx ] );
|
||||
SetSelection( idx, Mods!.ModSettings![ idx ] );
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -294,18 +299,8 @@ namespace Penumbra.UI
|
|||
|
||||
public void SelectModByName( string name )
|
||||
{
|
||||
for( var modIndex = 0; modIndex < Mods.ModSettings.Count; modIndex++ )
|
||||
{
|
||||
var mod = Mods.ModSettings[ modIndex ];
|
||||
|
||||
if( mod.Mod.Meta.Name != name )
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
SetSelection( modIndex, mod );
|
||||
return;
|
||||
}
|
||||
var idx = Mods?.ModSettings?.FindIndex( mod => mod.Mod.Meta.Name == name ) ?? -1;
|
||||
SetSelection( idx );
|
||||
}
|
||||
|
||||
private string GetCurrentModMetaFile()
|
||||
|
|
@ -316,18 +311,21 @@ namespace Penumbra.UI
|
|||
var metaPath = GetCurrentModMetaFile();
|
||||
if( metaPath.Length > 0 && File.Exists( metaPath ) )
|
||||
{
|
||||
_mod.Mod.Meta = ModMeta.LoadFromFile( metaPath ) ?? _mod.Mod.Meta;
|
||||
_mod!.Mod.Meta = ModMeta.LoadFromFile( metaPath ) ?? _mod.Mod.Meta;
|
||||
_base._menu.InstalledTab.ModPanel.Details.ResetState();
|
||||
}
|
||||
|
||||
_mod.Mod.RefreshModFiles();
|
||||
_mod!.Mod.RefreshModFiles();
|
||||
Service< ModManager >.Get().CalculateEffectiveFileList();
|
||||
_base._menu.EffectiveTab.RebuildFileList( _base._plugin.Configuration.ShowAdvanced );
|
||||
_base._menu.EffectiveTab.RebuildFileList( _base._plugin!.Configuration!.ShowAdvanced );
|
||||
ResetModNamesLower();
|
||||
}
|
||||
|
||||
public string SaveCurrentMod()
|
||||
{
|
||||
if( _mod == null )
|
||||
return "";
|
||||
|
||||
var metaPath = GetCurrentModMetaFile();
|
||||
if( metaPath.Length > 0 )
|
||||
{
|
||||
|
|
|
|||
|
|
@ -20,13 +20,13 @@ namespace Penumbra.UI
|
|||
private const string LabelReloadResource = "Reload Player Resource";
|
||||
|
||||
private readonly SettingsInterface _base;
|
||||
private readonly Configuration _config;
|
||||
private readonly Configuration _config;
|
||||
private bool _configChanged;
|
||||
|
||||
public TabSettings( SettingsInterface ui )
|
||||
{
|
||||
_base = ui;
|
||||
_config = _base._plugin.Configuration;
|
||||
_config = _base._plugin.Configuration!;
|
||||
_configChanged = false;
|
||||
}
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ namespace Penumbra.UI
|
|||
{
|
||||
_config.IsEnabled = enabled;
|
||||
_configChanged = true;
|
||||
Game.RefreshActors.RedrawAll( _base._plugin.PluginInterface.ClientState.Actors );
|
||||
Game.RefreshActors.RedrawAll( _base._plugin!.PluginInterface!.ClientState.Actors );
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,7 +131,7 @@ namespace Penumbra.UI
|
|||
{
|
||||
if( ImGui.Button( LabelReloadResource ) )
|
||||
{
|
||||
_base._plugin.GameUtils.ReloadPlayerResources();
|
||||
_base._plugin!.GameUtils!.ReloadPlayerResources();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,46 +3,32 @@ using System.Collections.Generic;
|
|||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
public class SingleOrArrayConverter< T > : JsonConverter
|
||||
namespace Penumbra.Util
|
||||
{
|
||||
public override bool CanConvert( Type objectType ) => objectType == typeof( HashSet< T > );
|
||||
|
||||
public override object ReadJson( JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer )
|
||||
public class SingleOrArrayConverter< T > : JsonConverter
|
||||
{
|
||||
var token = JToken.Load( reader );
|
||||
return token.Type == JTokenType.Array
|
||||
? token.ToObject< HashSet< T > >()
|
||||
: new HashSet< T > { token.ToObject< T >() };
|
||||
}
|
||||
public override bool CanConvert( Type objectType ) => objectType == typeof( HashSet< T > );
|
||||
|
||||
public override bool CanWrite => false;
|
||||
|
||||
public override void WriteJson( JsonWriter writer, object value, JsonSerializer serializer )
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
|
||||
public class DictSingleOrArrayConverter< T, U > : JsonConverter
|
||||
{
|
||||
public override bool CanConvert( Type objectType ) => objectType == typeof( Dictionary< T, HashSet< U > > );
|
||||
|
||||
public override object ReadJson( JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer )
|
||||
{
|
||||
var token = JToken.Load( reader );
|
||||
|
||||
if( token.Type == JTokenType.Array )
|
||||
public override object ReadJson( JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer )
|
||||
{
|
||||
return token.ToObject< HashSet< T > >();
|
||||
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 >();
|
||||
}
|
||||
|
||||
return new HashSet< T > { token.ToObject< T >() };
|
||||
}
|
||||
public override bool CanWrite => false;
|
||||
|
||||
public override bool CanWrite => false;
|
||||
|
||||
public override void WriteJson( JsonWriter writer, object value, JsonSerializer serializer )
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
public override void WriteJson( JsonWriter writer, object? value, JsonSerializer serializer )
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue