Improve archiving of mods, add initial management tabs, fix issues with sized strings.

This commit is contained in:
Ottermandias 2026-01-23 20:39:42 +01:00
parent e35b3f3608
commit d3363727e4
16 changed files with 939 additions and 327 deletions

2
Luna

@ -1 +1 @@
Subproject commit 06094555dc93eb302d7e823a84edab5926450db9
Subproject commit 2487453cfa4e95b9dbc661ed6e7298655df3b055

View file

@ -8,13 +8,11 @@ namespace Penumbra;
public enum ChangedItemMode
{
[Name("Grouped (Collapsed)")]
[Tooltip(
"Display items as groups by their model and slot. Collapse those groups to a single item by default. Prefers items with more changes affecting them or configured items as the main item.")]
[Tooltip("Display items as groups by their model and slot. Collapse those groups to a single item by default. Prefers items with more changes affecting them or configured items as the main item.")]
GroupedCollapsed,
[Name("Grouped (Expanded)")]
[Tooltip(
"Display items as groups by their model and slot. Expand those groups showing all items by default. Prefers items with more changes affecting them or configured items as the main item.")]
[Tooltip("Display items as groups by their model and slot. Expand those groups showing all items by default. Prefers items with more changes affecting them or configured items as the main item.")]
GroupedExpanded,
[Name("Alphabetical")]

View file

@ -1,4 +1,5 @@
using Penumbra.Mods.Manager;
using Penumbra.Util;
namespace Penumbra.Mods.Editor;
@ -70,7 +71,7 @@ public class ModBackup
}
/// <summary> Create a backup zip without blocking the main thread. </summary>
public async void CreateAsync()
public async Task CreateAsync()
{
if (CreatingBackup)
return;
@ -86,7 +87,7 @@ public class ModBackup
try
{
Delete();
ZipFile.CreateFromDirectory(_mod.ModPath.FullName, Name, CompressionLevel.Optimal, false);
ArchiveUtility.CreateFromDirectory(_mod.ModPath.FullName, Name);
Penumbra.Log.Debug($"Created export file {Name} from {_mod.ModPath.FullName}.");
}
catch (Exception e)
@ -126,7 +127,8 @@ public class ModBackup
Penumbra.Log.Debug($"Deleted mod folder {_mod.ModPath.FullName}.");
}
ZipFile.ExtractToDirectory(Name, _mod.ModPath.FullName);
ArchiveUtility.ExtractToDirectory(Name, _mod.ModPath.FullName);
Penumbra.Log.Debug($"Extracted exported file {Name} to {_mod.ModPath.FullName}.");
modManager.ReloadMod(_mod);
}

View file

@ -27,7 +27,7 @@ public class ModConfigUpdater : IDisposable, IRequiredService
public IEnumerable<(Mod, (string Plugin, string Notes)[])> ListUnusedMods(TimeSpan age)
{
var cutoff = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - (int)age.TotalMilliseconds;
var cutoff = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - (long)age.TotalMilliseconds;
var noteDictionary = new Dictionary<Assembly, (bool InUse, string Notes)>();
foreach (var mod in _mods)
{
@ -44,8 +44,7 @@ public class ModConfigUpdater : IDisposable, IRequiredService
continue;
// Check whether other plugins mark this mod as in use.
noteDictionary.Clear();
ModUsageQueried?.Invoke(mod.Name, mod.Identifier, noteDictionary);
QueryUsage(mod, noteDictionary);
if (noteDictionary.Values.Any(n => n.InUse))
continue;
@ -57,6 +56,19 @@ public class ModConfigUpdater : IDisposable, IRequiredService
}
}
public Dictionary<Assembly, (bool InUse, string Notes)> QueryUsage(Mod mod)
{
var noteDictionary = new Dictionary<Assembly, (bool InUse, string Notes)>();
ModUsageQueried?.Invoke(mod.Name, mod.Identifier, noteDictionary);
return noteDictionary;
}
public void QueryUsage(Mod mod, Dictionary<Assembly, (bool InUse, string Notes)> noteDictionary)
{
noteDictionary.Clear();
ModUsageQueried?.Invoke(mod.Name, mod.Identifier, noteDictionary);
}
private void OnModSettingChanged(in ModSettingChanged.Arguments arguments)
{
if (arguments.Inherited)

View file

@ -1,91 +1,97 @@
using Penumbra.Communication;
using Penumbra.Mods.Editor;
using Penumbra.Services;
namespace Penumbra.Mods.Manager;
public class ModExportManager : IDisposable, Luna.IService
{
private readonly Configuration _config;
private readonly CommunicatorService _communicator;
private readonly ModManager _modManager;
private DirectoryInfo? _exportDirectory;
public DirectoryInfo ExportDirectory
=> _exportDirectory ?? _modManager.BasePath;
public ModExportManager(Configuration config, CommunicatorService communicator, ModManager modManager)
{
_config = config;
_communicator = communicator;
_modManager = modManager;
UpdateExportDirectory(_config.ExportDirectory, false);
_communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModExportManager);
}
/// <inheritdoc cref="UpdateExportDirectory(string, bool)"/>
public void UpdateExportDirectory(string newDirectory)
=> UpdateExportDirectory(newDirectory, true);
/// <summary>
/// Update the export directory to a new directory. Can also reset it to null with empty input.
/// If the directory is changed, all existing backups will be moved to the new one.
/// </summary>
/// <param name="newDirectory">The new directory name.</param>
/// <param name="change">Can be used to stop saving for the initial setting</param>
private void UpdateExportDirectory(string newDirectory, bool change)
{
if (newDirectory.Length == 0)
{
if (_exportDirectory == null)
return;
_exportDirectory = null;
_config.ExportDirectory = string.Empty;
_config.Save();
return;
}
var dir = new DirectoryInfo(newDirectory);
if (dir.FullName.Equals(_exportDirectory?.FullName, StringComparison.OrdinalIgnoreCase))
return;
if (!dir.Exists)
try
{
Directory.CreateDirectory(dir.FullName);
}
catch (Exception e)
{
Penumbra.Log.Error($"Could not create Export Directory:\n{e}");
return;
}
if (change)
foreach (var mod in _modManager)
new ModBackup(this, mod).Move(dir.FullName);
_exportDirectory = dir;
if (!change)
return;
_config.ExportDirectory = dir.FullName;
_config.Save();
}
public void Dispose()
=> _communicator.ModPathChanged.Unsubscribe(OnModPathChange);
/// <summary> Automatically migrate the backup file to the new name if any exists. </summary>
private void OnModPathChange(in ModPathChanged.Arguments arguments)
{
if (arguments.Type is not ModPathChangeType.Moved || arguments.OldDirectory is null || arguments.NewDirectory is null)
return;
arguments.Mod.ModPath = arguments.OldDirectory;
new ModBackup(this, arguments.Mod).Move(null, arguments.NewDirectory.Name);
arguments.Mod.ModPath = arguments.NewDirectory;
}
}
using Penumbra.Communication;
using Penumbra.Mods.Editor;
using Penumbra.Services;
namespace Penumbra.Mods.Manager;
public class ModExportManager : IDisposable, Luna.IService
{
private readonly Configuration _config;
private readonly CommunicatorService _communicator;
private readonly ModManager _modManager;
private DirectoryInfo? _exportDirectory;
public DirectoryInfo ExportDirectory
=> _exportDirectory ?? _modManager.BasePath;
public ModExportManager(Configuration config, CommunicatorService communicator, ModManager modManager)
{
_config = config;
_communicator = communicator;
_modManager = modManager;
UpdateExportDirectory(_config.ExportDirectory, false);
_communicator.ModPathChanged.Subscribe(OnModPathChange, ModPathChanged.Priority.ModExportManager);
}
/// <inheritdoc cref="UpdateExportDirectory(string, bool)"/>
public void UpdateExportDirectory(string newDirectory)
=> UpdateExportDirectory(newDirectory, true);
public Task CreateAsync(Mod mod)
{
var backup = new ModBackup(this, mod);
return backup.CreateAsync();
}
/// <summary>
/// Update the export directory to a new directory. Can also reset it to null with empty input.
/// If the directory is changed, all existing backups will be moved to the new one.
/// </summary>
/// <param name="newDirectory">The new directory name.</param>
/// <param name="change">Can be used to stop saving for the initial setting</param>
private void UpdateExportDirectory(string newDirectory, bool change)
{
if (newDirectory.Length == 0)
{
if (_exportDirectory == null)
return;
_exportDirectory = null;
_config.ExportDirectory = string.Empty;
_config.Save();
return;
}
var dir = new DirectoryInfo(newDirectory);
if (dir.FullName.Equals(_exportDirectory?.FullName, StringComparison.OrdinalIgnoreCase))
return;
if (!dir.Exists)
try
{
Directory.CreateDirectory(dir.FullName);
}
catch (Exception e)
{
Penumbra.Log.Error($"Could not create Export Directory:\n{e}");
return;
}
if (change)
foreach (var mod in _modManager)
new ModBackup(this, mod).Move(dir.FullName);
_exportDirectory = dir;
if (!change)
return;
_config.ExportDirectory = dir.FullName;
_config.Save();
}
public void Dispose()
=> _communicator.ModPathChanged.Unsubscribe(OnModPathChange);
/// <summary> Automatically migrate the backup file to the new name if any exists. </summary>
private void OnModPathChange(in ModPathChanged.Arguments arguments)
{
if (arguments.Type is not ModPathChangeType.Moved || arguments.OldDirectory is null || arguments.NewDirectory is null)
return;
arguments.Mod.ModPath = arguments.OldDirectory;
new ModBackup(this, arguments.Mod).Move(null, arguments.NewDirectory.Name);
arguments.Mod.ModPath = arguments.NewDirectory;
}
}

View file

@ -52,7 +52,7 @@
<ItemGroup>
<PackageReference Include="EmbedIO" Version="3.5.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="SharpCompress" Version="0.40.0" />
<PackageReference Include="SharpCompress" Version="0.44.1" />
<PackageReference Include="SharpGLTF.Core" Version="1.0.5" />
<PackageReference Include="SharpGLTF.Toolkit" Version="1.0.5" />
<PackageReference Include="PeNet" Version="5.1.0" />

View file

@ -17,6 +17,7 @@ using Penumbra.Mods.Groups;
using Penumbra.Mods.Manager;
using Penumbra.Mods.SubMods;
using Penumbra.String.Classes;
using Penumbra.Util;
namespace Penumbra.Services;
@ -135,7 +136,7 @@ public class PcpService : IApiService, IDisposable
}
// Move to folder.
if (arguments.Mod.Node is {} node)
if (arguments.Mod.Node is { } node)
{
try
{
@ -204,7 +205,7 @@ public class PcpService : IApiService, IDisposable
path = directory.FullName + extension;
else if (Path.GetExtension(path.AsSpan()).IsEmpty)
path += extension;
ZipFile.CreateFromDirectory(directory.FullName, path, CompressionLevel.Optimal, false);
ArchiveUtility.CreateFromDirectory(directory.FullName, path);
directory.Delete(true);
return path;
}

View file

@ -0,0 +1,158 @@
using ImSharp;
using Luna;
using Penumbra.Collections;
using Penumbra.Collections.Manager;
using Penumbra.Mods;
using Penumbra.Mods.Manager;
namespace Penumbra.UI.ManagementTab;
public sealed class DuplicateModsTab(ModConfigUpdater configUpdater, ModManager mods, CollectionStorage collections, Configuration config)
: ITab<ManagementTabType>
{
public ReadOnlySpan<byte> Label
=> "Duplicate Mods"u8;
public ManagementTabType Identifier
=> ManagementTabType.DuplicateMods;
public void PostTabButton()
{
Im.Tooltip.OnHover(
"This tab shows mods with identical names and some additional data to discern which to keep if they are indeed identical."u8);
}
public void DrawContent()
{
using var child = Im.Child.Begin("c"u8);
if (!child)
return;
using var table = Im.Table.Begin("duplicates"u8, 7, TableFlags.RowBackground | TableFlags.ScrollY);
if (!table)
return;
var cache = CacheManager.Instance.GetOrCreateCache(Im.Id.Current, () => new Cache(configUpdater, mods, collections));
table.SetupScrollFreeze(0, 1);
table.SetupColumn(""u8, TableColumnFlags.WidthFixed, Im.Style.FrameHeight * 2 + Im.Style.ItemInnerSpacing.X);
table.SetupColumn("Mod Name"u8, TableColumnFlags.WidthStretch, 0.25f);
table.SetupColumn("Mod Directory"u8, TableColumnFlags.WidthStretch, 0.25f);
table.SetupColumn("Active"u8, TableColumnFlags.WidthFixed, cache.Active.Size.X);
table.SetupColumn("Import Date"u8, TableColumnFlags.WidthFixed, cache.Date.Size.X);
table.SetupColumn("Path"u8, TableColumnFlags.WidthStretch, 0.5f);
table.SetupColumn("Notes"u8, TableColumnFlags.WidthFixed, cache.Notes.Size.X + Im.Style.FrameHeight + Im.Style.ItemInnerSpacing.X);
table.HeaderRow();
var lastDrawnName = StringU8.Empty;
var disabled = !config.DeleteModModifier.IsActive();
using var clipper = new Im.ListClipper(cache.Items.Count, 0);
foreach (var (index, item) in cache.Items.Index())
{
using var id = Im.Id.Push(index);
table.NextColumn();
if (ImEx.Icon.Button(LunaStyle.DeleteIcon, "Delete this mod. This is NOT revertible."u8, disabled))
{
mods.DeleteMod(item.Mod);
cache.Dirty |= IManagedCache.DirtyFlags.Custom;
}
if (disabled)
Im.Tooltip.OnHover(HoveredFlags.AllowWhenDisabled, $"Hold {config.DeleteModModifier} to delete this mod.");
Im.Line.SameInner();
if (ImEx.Icon.Button(LunaStyle.FolderIcon, "Open this mod in the file explorer of your choice."u8))
Process.Start(new ProcessStartInfo(item.Mod.ModPath.FullName) { UseShellExecute = true });
if (lastDrawnName == item.Name)
{
table.NextColumn();
}
else
{
table.DrawFrameColumnWithTooltip(item.Name);
lastDrawnName = item.Name;
}
table.DrawFrameColumnWithTooltip(item.Directory);
table.NextColumn();
Im.Cursor.FrameAlign();
var count = item.Collections.Length + item.MarkedActive;
ImEx.TextRightAligned($"{count}");
if (count > 0 && Im.Item.Hovered())
{
using var tt = Im.Tooltip.Begin();
foreach (var collection in item.Collections)
{
Im.Text("Active in Collection "u8);
Im.Line.NoSpacing();
Im.Text(collection.Identity.Name);
}
if (item.MarkedActive > 0)
Im.Text($"{item.MarkedActive} other Plugins mark this mod as active.");
}
table.DrawFrameColumn(item.CreationDate);
table.DrawFrameColumnWithTooltip(item.Path);
table.NextColumn();
UnusedModsTab.DrawNotes(item.Notes);
}
}
private sealed class Cache(ModConfigUpdater configUpdater, ModManager mods, CollectionStorage collections) : BasicCache
{
public readonly record struct CacheItem(
StringU8 Name,
Mod Mod,
StringU8 Directory,
StringU8 CreationDate,
StringU8 Path,
ModCollection[] Collections,
(StringPair, StringPair)[] Notes,
int MarkedActive);
public List<CacheItem> Items = [];
public readonly SizedString Active = new("Active"u8);
public readonly SizedString Date = new("00/00/0000 00:00"u8);
public readonly SizedString Notes = new("Notes"u8);
public override void Update()
{
if (!Dirty.HasFlag(IManagedCache.DirtyFlags.Custom))
return;
Items.Clear();
var data = mods.GroupBy(m => m.Name).Select(g => (g.Key, g.ToList())).Where(p => p.Item2.Count > 1).ToList();
var dict = new Dictionary<Assembly, (bool, string)>();
foreach (var (name, list) in data)
{
var nameU8 = new StringU8(name);
foreach (var mod in list)
{
configUpdater.QueryUsage(mod, dict);
var creation = new StringU8($"{DateTimeOffset.FromUnixTimeMilliseconds(mod.ImportDate):g}");
var activeCollections = collections.Where(c => c.GetActualSettings(mod.Index).Settings?.Enabled is true).ToArray();
var notes = dict.Select(kvp => (new StringPair(kvp.Key.GetName().Name ?? "Unknown"), new StringPair(kvp.Value.Item2)))
.ToArray();
var markedActive = dict.Values.Count(v => v.Item1);
Items.Add(new CacheItem(nameU8, mod, new StringU8(mod.Identifier), creation, new StringU8(mod.Path.CurrentPath),
activeCollections, notes, markedActive));
}
}
Items = Items.OrderBy(i => i.Name).ThenByDescending(i => i.Collections.Length + i.MarkedActive)
.ThenByDescending(i => i.Notes.Length).ThenBy(i => i.Mod.ImportDate)
.ThenBy(i => i.Directory).ToList();
Dirty = IManagedCache.DirtyFlags.Clean;
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
Active.Dispose();
Date.Dispose();
Notes.Dispose();
}
}
}

View file

@ -1,10 +1,7 @@
using ImSharp;
using Luna;
using Penumbra.Api.Enums;
using Penumbra.Collections;
using Penumbra.Collections.Manager;
using Penumbra.Mods;
using Penumbra.Mods.Manager;
using Penumbra.Services;
namespace Penumbra.UI.ManagementTab;
@ -15,152 +12,7 @@ public enum ManagementTabType
Cleanup,
}
public sealed class DuplicateModsTab(ModManager mods, CollectionStorage collections) : ITab<ManagementTabType>
{
public ReadOnlySpan<byte> Label
=> "Duplicate Mods"u8;
public ManagementTabType Identifier
=> ManagementTabType.DuplicateMods;
public void DrawContent()
{
using var table = Im.Table.Begin("duplicates"u8, 4, TableFlags.RowBackground);
if (!table)
return;
var cache = CacheManager.Instance.GetOrCreateCache(Im.Id.Current, () => new Cache(mods, collections));
foreach (var item in cache.Items)
{
table.DrawFrameColumn(item.Name);
foreach (var (mod, date, path, enabledCollections) in item.Data)
{
table.GoToColumn(0);
table.DrawFrameColumn(path);
table.DrawFrameColumn(date);
table.DrawFrameColumn($"{enabledCollections.Length}");
if (Im.Item.Hovered())
{
using var tt = Im.Tooltip.Begin();
foreach (var collection in enabledCollections)
Im.Text(collection.Identity.Name);
}
table.NextRow();
}
}
}
private sealed class Cache(ModManager mods, CollectionStorage collections) : BasicCache
{
public readonly record struct CacheItem(
StringU8 Name,
(Mod Mod, StringU8 CreationDate, StringU8 Path, ModCollection[] Collections)[] Data);
public List<CacheItem> Items = [];
public override void Update()
{
if (!Dirty.HasFlag(IManagedCache.DirtyFlags.Custom))
return;
Items.Clear();
Items.AddRange(mods.GroupBy(m => m.Name)
.Select(kvp => new CacheItem(new StringU8(kvp.Key),
kvp.Select(m => (m, new StringU8($"{DateTimeOffset.FromUnixTimeMilliseconds(m.ImportDate)}"),
new StringU8(m.Path.CurrentPath),
collections.Where(c => c.GetActualSettings(m.Index).Settings?.Enabled is true).ToArray()))
.OrderByDescending(t => t.Item4.Length).ThenBy(t => t.m.ImportDate).ToArray())).Where(p => p.Data.Length > 1));
Dirty = IManagedCache.DirtyFlags.Clean;
}
}
}
public sealed class UnusedModsTab(ModConfigUpdater modConfigUpdater) : ITab<ManagementTabType>
{
public ReadOnlySpan<byte> Label
=> "Unused Mods"u8;
public ManagementTabType Identifier
=> ManagementTabType.UnusedMods;
public void DrawContent()
{
using var table = Im.Table.Begin("unused"u8, 5, TableFlags.RowBackground | TableFlags.SizingFixedFit);
if (!table)
return;
var cache = CacheManager.Instance.GetOrCreateCache(Im.Id.Current, () => new Cache(modConfigUpdater));
using var clipper = new Im.ListClipper(cache.Data.Count, Im.Style.FrameHeightWithSpacing);
foreach (var item in clipper.Iterate(cache.Data))
{
table.DrawFrameColumn(item.ModName);
table.DrawFrameColumn(item.ModPath);
table.DrawFrameColumn(item.Duration.Utf8);
table.DrawFrameColumn(item.ModSizeString.Utf8);
table.NextColumn();
if (item.Notes.Length > 0)
{
ImEx.Icon.DrawAligned(LunaStyle.InfoIcon, LunaStyle.FavoriteColor);
var hovered = Im.Item.Hovered();
Im.Line.SameInner();
Im.Text("Notes"u8);
if (hovered || Im.Item.Hovered())
{
using var tt = Im.Tooltip.Begin();
using (Im.Group())
{
foreach (var (plugin, _) in item.Notes)
Im.Text(plugin.Utf8);
}
using (Im.Group())
{
foreach (var (_, node) in item.Notes)
Im.Text(node.Utf8);
}
}
}
}
}
private sealed class Cache(ModConfigUpdater modConfigUpdater) : BasicCache
{
public readonly record struct CacheItem(
Mod Mod,
StringU8 ModName,
StringU8 ModPath,
long ModSize,
StringPair ModSizeString,
StringPair Duration,
(StringPair, StringPair)[] Notes)
{
public CacheItem(Mod mod, (string, string)[] Notes, DateTime now)
: this(mod, new StringU8(mod.Name), new StringU8(mod.Path.CurrentPath), 0, StringPair.Empty,
new StringPair(FormattingFunctions.DurationString(mod.LastConfigEdit, now)),
Notes.Select(n => (new StringPair(n.Item1), new StringPair(n.Item2))).ToArray())
{ }
}
public List<CacheItem> Data = [];
public override void Update()
{
if (!Dirty.HasFlag(IManagedCache.DirtyFlags.Custom))
return;
var now = DateTime.UtcNow;
Data.Clear();
foreach (var (mod, notes) in modConfigUpdater.ListUnusedMods(TimeSpan.Zero).OrderBy(mod => mod.Item1.LastConfigEdit))
Data.Add(new CacheItem(mod, notes, now));
Dirty = IManagedCache.DirtyFlags.Clean;
}
}
}
public sealed class CleanupTab : ITab<ManagementTabType>
public sealed class CleanupTab(CleanupService cleanup, Configuration config) : ITab<ManagementTabType>
{
public ReadOnlySpan<byte> Label
=> "General Cleanup"u8;
@ -169,7 +21,46 @@ public sealed class CleanupTab : ITab<ManagementTabType>
=> ManagementTabType.Cleanup;
public void DrawContent()
{ }
{
using var child = Im.Child.Begin("c"u8, Im.ContentRegion.Available);
if (!child)
return;
var enabled = config.DeleteModModifier.IsActive();
if (cleanup.Progress is not 0.0 and not 1.0)
{
Im.ProgressBar((float)cleanup.Progress, new Vector2(200 * Im.Style.GlobalScale, Im.Style.FrameHeight),
$"{cleanup.Progress * 100}%");
Im.Line.Same();
if (Im.Button("Cancel##FileCleanup"u8))
cleanup.Cancel();
}
else
{
Im.Line.New();
}
if (ImEx.Button("Clear Unused Local Mod Data Files"u8, default,
"Delete all local mod data files that do not correspond to currently installed mods."u8,
!enabled || cleanup.IsRunning))
cleanup.CleanUnusedLocalData();
if (!enabled)
Im.Tooltip.OnHover(HoveredFlags.AllowWhenDisabled, $"Hold {config.DeleteModModifier} while clicking to delete files.");
if (ImEx.Button("Clear Backup Files"u8, default,
"Delete all backups of .json configuration files in your configuration folder and all backups of mod group files in your mod directory."u8,
!enabled || cleanup.IsRunning))
cleanup.CleanBackupFiles();
if (!enabled)
Im.Tooltip.OnHover(HoveredFlags.AllowWhenDisabled, $"Hold {config.DeleteModModifier} while clicking to delete files.");
if (ImEx.Button("Clear All Unused Settings"u8, default,
"Remove all mod settings in all of your collections that do not correspond to currently installed mods."u8,
!enabled || cleanup.IsRunning))
cleanup.CleanupAllUnusedSettings();
if (!enabled)
Im.Tooltip.OnHover(HoveredFlags.AllowWhenDisabled, $"Hold {config.DeleteModModifier} while clicking to remove settings.");
}
}
public sealed class ManagementTab : TabBar<ManagementTabType>, ITab<TabType>

View file

@ -0,0 +1,556 @@
using ImSharp;
using ImSharp.Table;
using Luna;
using Penumbra.Api.Enums;
using Penumbra.Communication;
using Penumbra.Mods;
using Penumbra.Mods.Manager;
using Penumbra.Services;
using Penumbra.UI.Classes;
namespace Penumbra.UI.ManagementTab;
public sealed class UnusedModsTab(
ModConfigUpdater modConfigUpdater,
ModManager manager,
Configuration config,
ModExportManager exports,
CommunicatorService communicator) : ITab<ManagementTabType>
{
public ReadOnlySpan<byte> Label
=> "Unused Mods"u8;
public ManagementTabType Identifier
=> ManagementTabType.UnusedMods;
private readonly Table _table = new(modConfigUpdater, manager, config, exports, communicator);
private int _defaultDays = 30;
public void PostTabButton()
{
if (!Im.Item.Hovered())
return;
using var tt = Im.Tooltip.Begin();
ImEx.TextMultiColored("Here you can list mods that are not currently enabled or have temporary settings in "u8)
.Then("any "u8, ColorId.NewMod.Value()).Then(" collection."u8).End();
Im.Text(
"Other Plugins subscribing to Penumbras API can mark mods as 'in use' so that they do not appear, or add custom notes to them while still displaying them."u8);
}
public void DrawContent()
{
using var child = Im.Child.Begin("c"u8, Im.ContentRegion.Available);
if (!child)
return;
if (Im.Checkbox("Hide All Mods with Notes"u8, _table.HideNodes))
_table.HideNodes ^= true;
Im.Line.Same();
if (Im.Button("Show All Inactive Mods"u8))
_table.UnusedCap = TimeSpan.Zero;
Im.Line.Same();
if (Im.Button("Show Inactive Mods Not Configured in"u8))
_table.UnusedCap = TimeSpan.FromDays(_defaultDays);
Im.Line.SameInner();
Im.Item.SetNextWidthScaled(40);
Im.Drag("Days"u8, ref _defaultDays, 0, null, 0.1f, SliderFlags.AlwaysClamp);
_table.Draw();
}
private sealed class Table(
ModConfigUpdater modConfigUpdater,
ModManager manager,
Configuration config,
ModExportManager exports,
CommunicatorService communicator) : TableBase<CacheItem, Table.Cache>(new StringU8("unused"u8),
new ButtonColumn(manager, config, exports),
new NameColumn(communicator), new LastEditColumn(), new ModSizeColumn(), new PathColumn(), new NotesColumn())
{
public bool HideNodes
{
get;
set
{
if (field == value)
return;
field = value;
_filterDirty = true;
}
}
public TimeSpan UnusedCap
{
get;
set
{
if (field == value)
return;
field = value;
_spanDirty = true;
}
} = TimeSpan.MaxValue;
private bool _filterDirty;
private bool _spanDirty;
public override IEnumerable<CacheItem> GetItems()
{
var now = DateTime.UtcNow;
return modConfigUpdater.ListUnusedMods(UnusedCap).Select(m => new CacheItem(m.Item1, m.Item2, now));
}
public override Vector2 GetSize()
{
var size = Im.ContentRegion.Available;
size.Y -= Im.Style.TextHeightWithSpacing;
return size;
}
protected override void PreDraw(in Cache cache)
{
var buttons = (ButtonColumn)Columns[0];
buttons.DeleteList.Clear();
var disabled = !config.DeleteModModifier.IsActive();
Im.Line.Same();
if (ImEx.Button("Update View"u8,
"The table does not automatically update, so click this to update the visible mods without changing the time limit."u8))
cache.Dirty |= IManagedCache.DirtyFlags.Custom;
Im.Line.Same();
if (ImEx.Button("Delete All Visible Mods"u8, default, "Delete all mods that are currently visible. This is NOT reversible."u8,
disabled))
foreach (var (mod, globalIndex) in cache.GetItemsWithIndices().ToList())
{
manager.DeleteMod(mod.Mod);
cache.DeleteSingleItem(globalIndex);
}
if (disabled)
Im.Tooltip.OnHover(HoveredFlags.AllowWhenDisabled, $"\nHold {config.DeleteModModifier} to delete the mods.");
}
protected override void PostDraw(in Cache cache)
{
base.PostDraw(in cache);
var buttons = (ButtonColumn)Columns[0];
foreach (var item in buttons.DeleteList)
cache.DeleteSingleItem(item);
buttons.DeleteList.Clear();
if (cache.Loading)
return;
Im.Text($"{cache.Count}/{cache.AllItems.Count} visible of {manager.Count} total Mods.");
if (buttons.Exporting is { } mod)
{
Im.Line.Same();
Im.Text($"Exporting and deleting mod: {mod.Name}");
Im.Line.Same();
ImEx.Spinner("s"u8, Im.Style.TextHeight / 3, 2, Im.Color.Get(ImGuiColor.Text));
}
}
protected override Cache CreateCache()
=> new(this);
public sealed class Cache : TableCache<CacheItem>
{
private readonly Table _parent;
public Cache(Table parent)
: base(parent)
{
KeepAliveDuration = TimeSpan.FromMinutes(5);
_parent = parent;
}
protected override bool WouldBeVisible(in CacheItem value, int globalIndex)
=> (!_parent.HideNodes || value.Notes.Length == 0) && base.WouldBeVisible(value, globalIndex);
private CancellationTokenSource? _cancel;
public override void Update()
{
if (_parent._filterDirty)
{
FilterDirty = true;
_parent._filterDirty = false;
}
if (_parent._spanDirty)
{
Dirty |= IManagedCache.DirtyFlags.Custom;
_parent._spanDirty = false;
}
base.Update();
}
protected override void UpdateSort()
{
if (Loading)
return;
base.UpdateSort();
}
protected override void UpdateFilter()
{
if (Loading)
return;
base.UpdateFilter();
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
_cancel?.Cancel();
}
protected override void OnDataUpdate()
{
_cancel?.Cancel();
base.OnDataUpdate();
if (UnfilteredItems.Count < 50)
return;
Loading = true;
_cancel = new CancellationTokenSource();
var token = _cancel.Token;
Task.Run(() =>
{
foreach (var mod in UnfilteredItems)
{
if (token.IsCancellationRequested)
{
Loading = false;
return;
}
_ = mod.ModSizeString;
}
Loading = false;
}, token);
}
}
}
private sealed class ButtonColumn : BasicColumn<CacheItem>
{
public readonly HashSet<int> DeleteList = [];
private readonly ModManager _manager;
private readonly Configuration _config;
private readonly ModExportManager _exports;
public Mod? Exporting { get; private set; }
public ButtonColumn(ModManager manager, Configuration config, ModExportManager exports)
{
_manager = manager;
_config = config;
_exports = exports;
Label = StringU8.Empty;
Flags |= TableColumnFlags.NoSort;
}
public override void DrawColumn(in CacheItem item, int globalIndex)
{
var inactive = !_config.DeleteModModifier.IsActive();
var exportingThis = Exporting == item.Mod;
if (ImEx.Icon.Button(LunaStyle.DeleteIcon, "Delete this mod from Penumbra and your drive. This is NOT reversible."u8,
inactive || exportingThis))
{
_manager.DeleteMod(item.Mod);
DeleteList.Add(globalIndex);
}
if (inactive)
Im.Tooltip.OnHover(HoveredFlags.AllowWhenDisabled, $"\nHold {_config.DeleteModModifier} to delete.");
if (exportingThis)
Im.Tooltip.OnHover(HoveredFlags.AllowWhenDisabled, "\nCurrently exporting and deleting this mod, please wait."u8);
Im.Line.SameInner();
var exporting = Exporting is not null;
if (ImEx.Icon.Button(LunaStyle.BackupDeleteIcon,
"Export this mod to your export directory, compressing it, and then delete it from Penumbra."u8, inactive || exporting))
{
Exporting = item.Mod;
_exports.CreateAsync(Exporting).ContinueWith(_ =>
{
_manager.DeleteMod(Exporting);
DeleteList.Add(globalIndex);
Exporting = null;
});
}
if (inactive)
Im.Tooltip.OnHover(HoveredFlags.AllowWhenDisabled, $"\nHold {_config.DeleteModModifier} to delete.");
if (exporting)
Im.Tooltip.OnHover(HoveredFlags.AllowWhenDisabled, "Already exporting and deleting a mod, please wait."u8);
Im.Line.SameInner();
if (ImEx.Icon.Button(LunaStyle.FolderIcon, "Open the mod directory in the file explorer of your choice."u8))
Process.Start(new ProcessStartInfo(item.Mod.ModPath.FullName) { UseShellExecute = true });
}
public override float ComputeWidth(IEnumerable<CacheItem> _)
=> Im.Style.FrameHeight * 3 + 2 * Im.Style.ItemInnerSpacing.X;
}
private sealed class NameColumn : TextColumn<CacheItem>
{
private readonly CommunicatorService _communicator;
public NameColumn(CommunicatorService communicator)
{
_communicator = communicator;
Label = new StringU8("Mod Name"u8);
Flags |= TableColumnFlags.WidthStretch;
}
protected override string ComparisonText(in CacheItem item, int globalIndex)
=> item.Mod.Name;
protected override StringU8 DisplayText(in CacheItem item, int globalIndex)
=> item.ModName;
public override float ComputeWidth(IEnumerable<CacheItem> _)
=> 0.3f;
public override void DrawColumn(in CacheItem item, int globalIndex)
{
var content = Im.ContentRegion.Available.X;
Im.Cursor.FrameAlign();
var clicked = Im.Selectable(item.ModName);
Im.Tooltip.OnHover(item.DirectoryName);
if (Im.Font.CalculateSize(item.ModName).X >= content)
Im.Tooltip.OnHover(item.ModName);
Im.Tooltip.OnHover("\nClick to move to mod."u8);
if (clicked)
_communicator.SelectTab.Invoke(new SelectTab.Arguments(TabType.Mods, item.Mod));
}
}
private sealed class PathColumn : TextColumn<CacheItem>
{
public PathColumn()
{
Label = new StringU8("Mod Path"u8);
Flags |= TableColumnFlags.WidthStretch;
}
protected override string ComparisonText(in CacheItem item, int globalIndex)
=> item.Mod.Path.CurrentPath;
protected override StringU8 DisplayText(in CacheItem item, int globalIndex)
=> item.ModPath;
public override float ComputeWidth(IEnumerable<CacheItem> _)
=> 0.7f;
public override void DrawColumn(in CacheItem item, int globalIndex)
{
var content = Im.ContentRegion.Available.X;
Im.Cursor.FrameAlign();
base.DrawColumn(in item, globalIndex);
if (Im.Item.Size.X >= content)
Im.Tooltip.OnHover(item.ModPath);
}
}
private sealed class LastEditColumn : NumberColumn<long, CacheItem>
{
public LastEditColumn()
{
Label = new StringU8("Last Config Edit"u8);
}
public override long ToValue(in CacheItem item, int globalIndex)
=> item.Mod.LastConfigEdit;
protected override SizedString DisplayNumber(in CacheItem item, int globalIndex)
=> item.Duration;
protected override string ComparisonText(in CacheItem item, int globalIndex)
=> item.Duration.Utf16;
public override float ComputeWidth(IEnumerable<CacheItem> _)
=> Im.Font.CalculateSize("Last Config Edit"u8).X + ImEx.Table.ArrowWidth + Im.Style.CellPadding.X * 2;
public override void DrawColumn(in CacheItem item, int globalIndex)
{
Im.Cursor.FrameAlign();
base.DrawColumn(in item, globalIndex);
Im.Tooltip.OnHover($"Click to copy Timestamp: {item.Mod.LastConfigEdit}");
if (Im.Item.Clicked())
Im.Clipboard.Set($"{item.Mod.LastConfigEdit}");
}
}
private sealed class ModSizeColumn : NumberColumn<long, CacheItem>
{
public ModSizeColumn()
{
Label = new StringU8("Size On Disk"u8);
}
public override long ToValue(in CacheItem item, int globalIndex)
=> item.ModSize;
protected override SizedString DisplayNumber(in CacheItem item, int globalIndex)
=> item.ModSizeString;
protected override string ComparisonText(in CacheItem item, int globalIndex)
=> item.ModSizeString.Utf16;
public override float ComputeWidth(IEnumerable<CacheItem> _)
=> Im.Font.CalculateSize("Size On Disk"u8).X + ImEx.Table.ArrowWidth + Im.Style.CellPadding.X * 2;
public override void DrawColumn(in CacheItem item, int globalIndex)
{
Im.Cursor.FrameAlign();
base.DrawColumn(in item, globalIndex);
}
}
private sealed class NotesColumn : TextColumn<CacheItem>
{
private static readonly StringU8 Notes = new("Notes"u8);
public NotesColumn()
{
Label = new StringU8("Notes"u8);
}
public override bool WouldBeVisible(in CacheItem item, int globalIndex)
{
if (item.Notes.Length is 0)
return Filter.WouldBeVisible(string.Empty);
if (Filter.WouldBeVisible("Notes"))
return true;
return item.Notes.Any(n => Filter.WouldBeVisible(n.Item1.Utf16) || Filter.WouldBeVisible(n.Item2.Utf16));
}
public override int Compare(in CacheItem lhs, int lhsGlobalIndex, in CacheItem rhs, int rhsGlobalIndex)
{
var first = lhs.Notes.Length.CompareTo(rhs.Notes.Length);
if (first is not 0)
return first;
foreach (var (lhsNote, rhsNote) in lhs.Notes.Zip(rhs.Notes))
{
var second = lhsNote.Item1.Utf16.CompareTo(rhsNote.Item1.Utf16, StringComparison.Ordinal);
if (second is not 0)
return second;
}
foreach (var (lhsNote, rhsNote) in lhs.Notes.Zip(rhs.Notes))
{
var third = lhsNote.Item2.Utf16.Length.CompareTo(rhsNote.Item2.Utf16.Length);
if (third is not 0)
return third;
}
return 0;
}
protected override string ComparisonText(in CacheItem item, int globalIndex)
=> item.Notes.Length is 0 ? string.Empty : "Notes";
protected override StringU8 DisplayText(in CacheItem item, int globalIndex)
=> item.Notes.Length is 0 ? StringU8.Empty : Notes;
public override void DrawColumn(in CacheItem item, int globalIndex)
=> DrawNotes(item.Notes);
public override float ComputeWidth(IEnumerable<CacheItem> _)
=> Im.Style.FrameHeightWithSpacing + Im.Font.CalculateSize(Notes).X;
}
private record CacheItem(
Mod Mod,
StringU8 ModName,
StringU8 ModPath,
StringU8 DirectoryName,
long ModSize,
SizedStringPair ModSizeString,
SizedStringPair Duration,
(StringPair, StringPair)[] Notes) : IDisposable
{
public long ModSize
{
get => field < 0 ? field = WindowsFunctions.GetDirectorySize(Mod.ModPath.FullName) : field;
} = ModSize;
private SizedStringPair _modSizeString = ModSizeString;
public SizedStringPair ModSizeString
{
get => _modSizeString.IsEmpty
? _modSizeString = new SizedStringPair(FormattingFunctions.HumanReadableSize(ModSize))
: _modSizeString;
}
public CacheItem(Mod mod, (string, string)[] notes, DateTime now)
: this(mod, new StringU8(mod.Name), new StringU8(mod.Path.CurrentPath), new StringU8($"Directory Name: {mod.Identifier}"),
-1, SizedStringPair.Empty, new SizedStringPair(FormattingFunctions.DurationString(mod.LastConfigEdit, now)),
notes.Select(n => (new StringPair(n.Item1), new StringPair(n.Item2))).ToArray())
{ }
public void Dispose()
{
if (!_modSizeString.IsEmpty)
_modSizeString.Dispose();
Duration.Dispose();
}
}
public static void DrawNotes((StringPair, StringPair)[] notes)
{
if (notes.Length is 0)
return;
ImEx.Icon.DrawAligned(LunaStyle.InfoIcon, LunaStyle.FavoriteColor);
var hovered = Im.Item.Hovered();
Im.Line.SameInner();
Im.Text("Notes"u8);
if (!hovered && !Im.Item.Hovered())
return;
using var tt = Im.Tooltip.Begin();
DrawNote(notes[0]);
foreach (var note in notes.Skip(1))
{
Im.Separator();
DrawNote(note);
}
return;
static void DrawNote((StringPair, StringPair) note)
{
using (Im.Group())
{
Im.Text(note.Item1.Utf8);
Im.Line.Same();
using (Im.Group())
{
Im.Text(note.Item2.Utf8);
}
}
}
}
}

View file

@ -117,7 +117,7 @@ public class ModPanelEditTab(
: backup.Exists
? $"Overwrite current exported mod \"{backup.Name}\" with current mod."
: $"Create exported archive of current mod at \"{backup.Name}\".", ModBackup.CreatingBackup))
backup.CreateAsync();
_ = backup.CreateAsync();
if (Im.Item.RightClicked())
Im.Popup.Open("context"u8);

View file

@ -74,7 +74,7 @@ public sealed class ModFileSystemCache : FileSystemCache<ModFileSystemCache.ModD
}
}
public sealed class ModData(IFileSystemData<Mod> node) : BaseFileSystemNodeCache<ModData>
public sealed class ModData(IFileSystemData<Mod> node) : BaseFileSystemNodeCache<ModData>, IDisposable
{
public readonly IFileSystemData<Mod> Node = node;
public Vector4 TextColor;
@ -82,6 +82,7 @@ public sealed class ModFileSystemCache : FileSystemCache<ModFileSystemCache.ModD
public SizedString PriorityText = SizedString.Empty;
public ModSettings? Settings;
public ModCollection Collection = ModCollection.Empty;
public StringU8 Name = new(node.Value.Name);
public override void Update(FileSystemCache cache, IFileSystemNode node)
{
@ -92,7 +93,8 @@ public sealed class ModFileSystemCache : FileSystemCache<ModFileSystemCache.ModD
var priority = Settings?.Priority ?? ModPriority.Default;
if (priority != Priority)
{
Priority = priority;
Priority = priority;
PriorityText.Dispose();
PriorityText = priority.IsDefault ? SizedString.Empty : new SizedString($"[{priority}]");
}
}
@ -120,8 +122,10 @@ public sealed class ModFileSystemCache : FileSystemCache<ModFileSystemCache.ModD
{
using var color = ImGuiColor.Text.Push(TextColor)
.Push(ImGuiColor.HeaderHovered, 0x4000FFFF, Node.Value.Favorite);
using var id = Im.Id.Push(Node.Value.Index);
base.DrawInternal(cache, node);
using var id = Im.Id.Push(Node.Value.Index);
const TreeNodeFlags baseFlags = TreeNodeFlags.NoTreePushOnOpen;
var flags = node.Selected ? baseFlags | TreeNodeFlags.Selected : baseFlags;
Im.Tree.Leaf(Name, flags);
if (Im.Item.MiddleClicked())
OnMiddleClick(cache);
DrawPriority(cache);
@ -181,6 +185,9 @@ public sealed class ModFileSystemCache : FileSystemCache<ModFileSystemCache.ModD
if (offset > Im.Style.ItemSpacing.X)
Im.Window.DrawList.Text(new Vector2(itemPos + offset, line), ColorId.SelectorPriority.Value().Color, PriorityText.Text);
}
public void Dispose()
=> PriorityText.Dispose();
}
public override void Update()

View file

@ -43,7 +43,7 @@ public static class LoadStateExtensions
| LoadStateFlag.None;
}
internal sealed unsafe class CachedRecord(Record record)
internal sealed unsafe class CachedRecord(Record record) : IDisposable
{
public readonly Record Record = record;
public readonly string PathU16 = record.Path.ToString();
@ -58,6 +58,12 @@ internal sealed unsafe class CachedRecord(Record record)
public readonly string HandleU16 = $"0x{(nint)record.Handle:X}";
public readonly SizedStringPair Thread = new($"{record.OsThreadId}");
public readonly SizedStringPair RefCount = new($"{record.RefCount}");
public void Dispose()
{
Thread.Dispose();
RefCount.Dispose();
}
}
internal sealed class ResourceWatcherTable : TableBase<CachedRecord, TableCache<CachedRecord>>

View file

@ -872,8 +872,6 @@ public sealed class SettingsTab : ITab<TabType>
Im.Separator();
DrawReloadResourceButton();
DrawReloadFontsButton();
Im.Separator();
DrawCleanupButtons();
Im.Line.New();
}
@ -1053,43 +1051,6 @@ public sealed class SettingsTab : ITab<TabType>
_fontReloader.Reload();
}
private void DrawCleanupButtons()
{
var enabled = _config.DeleteModModifier.IsActive();
if (_cleanupService.Progress is not 0.0 and not 1.0)
{
Im.ProgressBar((float)_cleanupService.Progress, new Vector2(200 * Im.Style.GlobalScale, Im.Style.FrameHeight),
$"{_cleanupService.Progress * 100}%");
Im.Line.Same();
if (Im.Button("Cancel##FileCleanup"u8))
_cleanupService.Cancel();
}
else
{
Im.Line.New();
}
if (ImEx.Button("Clear Unused Local Mod Data Files"u8, default,
"Delete all local mod data files that do not correspond to currently installed mods."u8,
!enabled || _cleanupService.IsRunning))
_cleanupService.CleanUnusedLocalData();
if (!enabled)
Im.Tooltip.OnHover(HoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking to delete files.");
if (ImEx.Button("Clear Backup Files"u8, default,
"Delete all backups of .json configuration files in your configuration folder and all backups of mod group files in your mod directory."u8,
!enabled || _cleanupService.IsRunning))
_cleanupService.CleanBackupFiles();
if (!enabled)
Im.Tooltip.OnHover(HoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking to delete files.");
if (ImEx.Button("Clear All Unused Settings"u8, default,
"Remove all mod settings in all of your collections that do not correspond to currently installed mods."u8,
!enabled || _cleanupService.IsRunning))
_cleanupService.CleanupAllUnusedSettings();
if (!enabled)
Im.Tooltip.OnHover(HoveredFlags.AllowWhenDisabled, $"Hold {_config.DeleteModModifier} while clicking to remove settings.");
}
/// <summary> Draw a checkbox that toggles the dalamud setting to wait for plugins on open. </summary>
private void DrawWaitForPluginsReflection()

View file

@ -0,0 +1,29 @@
using SharpCompress.Archives;
using SharpCompress.Common;
namespace Penumbra.Util;
public static class ArchiveUtility
{
//private static readonly ZipWriterOptions DefaultArchiveOptions = new(CompressionType.LZMA, CompressionLevel.Level0);
private static readonly ExtractionOptions ExtractionOptions = new()
{
ExtractFullPath = true,
Overwrite = true,
};
public static void CreateFromDirectory(string directoryPath, string filePath)
{
ZipFile.CreateFromDirectory(directoryPath, filePath, CompressionLevel.SmallestSize, false);
//using var archive = ZipArchive.Create();
//archive.AddAllFromDirectory(directoryPath);
//archive.SaveTo(filePath, DefaultArchiveOptions);
}
public static void ExtractToDirectory(string filePath, string directoryPath)
{
Directory.CreateDirectory(directoryPath);
ArchiveFactory.WriteToDirectory(filePath, directoryPath, ExtractionOptions);
}
}

View file

@ -29,12 +29,9 @@
},
"SharpCompress": {
"type": "Direct",
"requested": "[0.40.0, )",
"resolved": "0.40.0",
"contentHash": "yP/aFX1jqGikVF7u2f05VEaWN4aCaKNLxSas82UgA2GGVECxq/BcqZx3STHCJ78qilo1azEOk1XpBglIuGMb7w==",
"dependencies": {
"ZstdSharp.Port": "0.8.5"
}
"requested": "[0.44.1, )",
"resolved": "0.44.1",
"contentHash": "QHhxJHoyIXKeu3vLJ0XY2rDJLd4fLvZ0xXPCMO854OOcSz62uHB5uPloBMtg236MFDMVUSUHuO1E8WY5HpLp6Q=="
},
"SharpGLTF.Core": {
"type": "Direct",
@ -69,21 +66,21 @@
},
"JetBrains.Annotations": {
"type": "Transitive",
"resolved": "2024.3.0",
"contentHash": "ox5pkeLQXjvJdyAB4b2sBYAlqZGLh3PjSnP1bQNVx72ONuTJ9+34/+Rq91Fc0dG29XG9RgZur9+NcP4riihTug=="
"resolved": "2024.2.0",
"contentHash": "GNnqCFW/163p1fOehKx0CnAqjmpPrUSqrgfHM6qca+P+RN39C9rhlfZHQpJhxmQG/dkOYe/b3Z0P8b6Kv5m1qw=="
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "9.0.2",
"contentHash": "ZffbJrskOZ40JTzcTyKwFHS5eACSWp2bUQBBApIgGV+es8RaTD4OxUG7XxFr3RIPLXtYQ1jQzF2DjKB5fZn7Qg==",
"resolved": "9.0.0",
"contentHash": "MCPrg7v3QgNMr0vX4vzRXvkNGgLg8vKWX0nKCWUxu2uPyMsaRgiRc1tHBnbTcfJMhMKj2slE/j2M9oGkd25DNw==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.2"
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "9.0.2",
"contentHash": "MNe7GSTBf3jQx5vYrXF0NZvn6l7hUKF6J54ENfAgCO8y6xjN1XUmKKWG464LP2ye6QqDiA1dkaWEZBYnhoZzjg=="
"resolved": "9.0.0",
"contentHash": "+6f2qv2a3dLwd5w6JanPIPs47CxRbnk+ZocMJUhv9NxP88VlOcJYZs9jY+MYSjxvady08bUZn6qgiNh7DadGgg=="
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
@ -145,11 +142,6 @@
"resolved": "3.1.0",
"contentHash": "X3s5QE/KMj3WAPFqFve7St+Ds10BB50u8kW8PmKIn7FVkn7yEXe9Yxr2htt1WV85DRqfFR0MN/BUNHkGHtL4OQ=="
},
"ZstdSharp.Port": {
"type": "Transitive",
"resolved": "0.8.5",
"contentHash": "TR4j17WeVSEb3ncgL2NqlXEqcy04I+Kk9CaebNDplUeL8XOgjkZ7fP4Wg4grBdPLIqsV86p2QaXTkZoRMVOsew=="
},
"luna": {
"type": "Project",
"dependencies": {
@ -158,13 +150,6 @@
"System.IO.Hashing": "[9.0.8, )"
}
},
"ottergui": {
"type": "Project",
"dependencies": {
"JetBrains.Annotations": "[2024.3.0, )",
"Microsoft.Extensions.DependencyInjection": "[9.0.2, )"
}
},
"penumbra.api": {
"type": "Project"
},
@ -177,7 +162,7 @@
"FlatSharp.Compiler": "[7.9.0, )",
"FlatSharp.Runtime": "[7.9.0, )",
"Luna": "[1.0.0, )",
"Penumbra.Api": "[5.13.0, )",
"Penumbra.Api": "[5.14.0, )",
"Penumbra.String": "[1.0.7, )"
}
},