Batch Editor: undo, enhancements

if you close the form after running a batch edit, it allows you to undo
if you want to undo while the form is still up, you can reset back to the initial state
buttons only enable when sufficient progress/available slots (so it doesn't run on 0/0)
shows a live-updating count of how many pkm slots will be edited by your currently entered command
This commit is contained in:
Kurt
2026-08-16 02:30:29 -05:00
parent 2d970dde75
commit c0b78e6b6a
8 changed files with 852 additions and 305 deletions

View File

@@ -1,76 +1,214 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace PKHeX.Core;
/// <summary>
/// Tracks <see cref="PKM"/> slot changes and provides the ability to revert a change.
/// Maintains undo and redo history for changes made to a <see cref="SaveFile"/>.
/// </summary>
public sealed class SlotChangelog(SaveFile SAV)
public sealed record SlotChangelog(SaveFile Parent)
{
private readonly Stack<SlotReversion> UndoStack = new();
private readonly Stack<SlotReversion> RedoStack = new();
private readonly Stack<ISlotReversion> _undo = new();
private readonly Stack<ISlotReversion> _redo = new();
public bool CanUndo => UndoStack.Count != 0;
public bool CanRedo => RedoStack.Count != 0;
public bool CanUndo => _undo.Count != 0;
public bool CanRedo => _redo.Count != 0;
public void AddNewChange(ISlotInfo info)
/// <summary>
/// Begins tracking a change affecting one slot.
/// </summary>
public Change Begin(ISlotInfo info) => Begin([info]);
/// <summary>
/// Begins tracking a change affecting multiple slots.
/// The resulting change is represented by a single undo/redo entry.
/// </summary>
public Change Begin(params IEnumerable<ISlotInfo> slots)
{
var revert = GetReversion(info, SAV);
AddUndo(revert);
var reversion = CreateReversion(slots, Parent);
return new Change(this, reversion);
}
public ISlotInfo Undo()
/// <summary>
/// Undoes the most recent committed change.
/// </summary>
/// <returns>The slots affected by the change.</returns>
public IReadOnlyList<ISlotInfo> Undo()
{
var change = UndoStack.Pop();
var revert = GetReversion(change.Info, SAV);
AddRedo(revert);
change.Revert(SAV);
return change.Info;
if (!CanUndo)
return [];
var change = _undo.Pop();
// Capture the state that exists immediately before the undo.
// That state becomes the redo operation.
var redo = change.CreateInverse(Parent);
change.Revert(Parent);
_redo.Push(redo);
return change.Slots;
}
public ISlotInfo Redo()
/// <summary>
/// Redoes the most recently undone change.
/// </summary>
/// <returns>The slots affected by the change.</returns>
public IReadOnlyList<ISlotInfo> Redo()
{
var change = RedoStack.Pop();
var revert = GetReversion(change.Info, SAV);
AddUndo(revert);
change.Revert(SAV);
return change.Info;
if (!CanRedo)
return [];
var change = _redo.Pop();
// Capture the state that exists immediately before the redo.
// That state becomes the next undo operation.
var undo = change.CreateInverse(Parent);
change.Revert(Parent);
_undo.Push(undo);
return change.Slots;
}
private void AddRedo(SlotReversion change)
private void Commit(ISlotReversion slotReversion)
{
RedoStack.Push(change);
_undo.Push(slotReversion);
_redo.Clear();
}
private void AddUndo(SlotReversion change)
// ReSharper disable once MemberCanBeMadeStatic.Local
// ReSharper disable once UnusedParameter.Local
private void Discard(ISlotReversion slotReversion)
{
UndoStack.Push(change);
RedoStack.Clear();
// A discarded change was never committed, so there is nothing to do.
// This method exists to keep Change's lifecycle explicit.
}
private static SlotReversion GetReversion(ISlotInfo info, SaveFile sav) => info switch
private static ISlotReversion CreateReversion(IEnumerable<ISlotInfo> slots, SaveFile parent)
{
SlotInfoParty p => new PartyReversion(p, sav),
_ => new SingleSlotReversion(info, sav),
var reversions = slots.Select(info => CreateReversion(info, parent)).ToArray();
return reversions.Length switch
{
0 => throw new ArgumentException("At least one slot is required.", nameof(slots)),
1 => reversions[0],
_ => new CompositeSlotReversion(reversions),
};
}
private static ISlotReversion CreateReversion(ISlotInfo info, SaveFile parent) => info switch
{
SlotInfoParty party => new PartySlotReversion(party, parent),
_ => new SlotSlotReversion(info, parent),
};
private abstract class SlotReversion(ISlotInfo Info)
/// <summary>
/// Represents a change being prepared for commit.
/// Capture the change before modifying the save, then call <see cref="Commit"/>
/// after the modification succeeds.
/// </summary>
public sealed class Change(SlotChangelog owner, ISlotReversion slotReversion) : IDisposable
{
internal readonly ISlotInfo Info = Info;
public abstract void Revert(SaveFile sav);
private bool _isCompleted;
/// <summary>
/// Commits the change to the undo history.
/// </summary>
public void Commit()
{
if (_isCompleted)
throw new InvalidOperationException("The change has already been completed.");
_isCompleted = true;
owner.Commit(slotReversion);
}
/// <summary>
/// Restores the state captured when the change began and discards it.
/// </summary>
public void Rollback()
{
if (_isCompleted)
throw new InvalidOperationException("The change has already been completed.");
_isCompleted = true;
slotReversion.Revert(owner.Parent);
owner.Discard(slotReversion);
}
/// <summary>
/// Discards the change without restoring anything.
/// Use this when no mutation was performed.
/// </summary>
public void Cancel()
{
if (_isCompleted)
throw new InvalidOperationException("The change has already been completed.");
_isCompleted = true;
owner.Discard(slotReversion);
}
public void Dispose()
{
// An uncommitted change is automatically rolled back.
if (!_isCompleted)
Rollback();
}
}
private sealed class PartyReversion(ISlotInfo info, IList<PKM> Party) : SlotReversion(info)
public interface ISlotReversion
{
public PartyReversion(ISlotInfo info, SaveFile s) : this(info, s.PartyData) { }
public override void Revert(SaveFile sav) => sav.PartyData = Party;
IReadOnlyList<ISlotInfo> Slots { get; }
ISlotReversion CreateInverse(SaveFile parent);
void Revert(SaveFile parent);
}
private sealed class SingleSlotReversion(ISlotInfo info, PKM Entity) : SlotReversion(info)
private sealed class SlotSlotReversion(ISlotInfo info, SaveFile parent) : ISlotReversion
{
public SingleSlotReversion(ISlotInfo info, SaveFile sav) : this(info, info.Read(sav)) { }
private readonly PKM _entity = info.Read(parent).Clone();
public IReadOnlyList<ISlotInfo> Slots => [info];
public ISlotReversion CreateInverse(SaveFile parent) => new SlotSlotReversion(info, parent);
public void Revert(SaveFile parent) => info.WriteTo(parent, _entity.Clone(), EntityImportSettings.None);
}
public override void Revert(SaveFile sav) => Info.WriteTo(sav, Entity, EntityImportSettings.None);
private sealed class PartySlotReversion(SlotInfoParty info, SaveFile parent) : ISlotReversion
{
private readonly PKM[] _party = CloneParty(parent.PartyData);
public IReadOnlyList<ISlotInfo> Slots => [info];
public ISlotReversion CreateInverse(SaveFile parent) => new PartySlotReversion(info, parent);
public void Revert(SaveFile parent) => parent.PartyData = CloneParty(_party);
private static PKM[] CloneParty(IEnumerable<PKM> party) => [.. party.Select(pk => pk.Clone())];
}
private sealed class CompositeSlotReversion : ISlotReversion
{
private readonly IReadOnlyList<ISlotReversion> Reversions;
public CompositeSlotReversion(params IReadOnlyList<ISlotReversion> reversions)
{
if (reversions.Count == 0)
throw new ArgumentException("At least one reversion is required.", nameof(reversions));
Reversions = reversions;
}
public IReadOnlyList<ISlotInfo> Slots => [.. Reversions.SelectMany(x => x.Slots)];
public ISlotReversion CreateInverse(SaveFile parent)
{
// Capture every inverse before modifying the save.
var inverse = Reversions
.Select(x => x.CreateInverse(parent))
.ToArray();
return new CompositeSlotReversion(inverse);
}
public void Revert(SaveFile parent)
{
foreach (var reversion in Reversions)
reversion.Revert(parent);
}
}
}

View File

@@ -1,3 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace PKHeX.Core;
/// <summary>
@@ -10,6 +14,15 @@ public sealed class SlotEditor<T>(SaveFile SAV)
private void NotifySlotChanged(ISlotInfo slot, SlotTouchType type, PKM pk) => Publisher.NotifySlotChanged(slot, type, pk);
/// <summary>
/// Notifies subscribers that a slot was modified externally.
/// </summary>
public void UpdateSlot(ISlotInfo slot)
{
var pk = slot.Read(SAV);
NotifySlotChanged(slot, SlotTouchType.Set, pk);
}
/// <summary>
/// Gets data from a slot.
/// </summary>
@@ -35,7 +48,12 @@ public SlotTouchResult Set(ISlotInfo slot, PKM pk, SlotTouchType type = SlotTouc
if (!slot.CanWriteTo(SAV))
return SlotTouchResult.FailWrite;
WriteSlot(slot, pk, type);
using var change = Changelog.Begin(slot);
if (!slot.WriteTo(SAV, pk, EntityImportSettings.None))
return SlotTouchResult.FailWrite;
change.Commit();
NotifySlotChanged(slot, type, pk);
return SlotTouchResult.Success;
}
@@ -49,14 +67,21 @@ public SlotTouchResult Delete(ISlotInfo slot)
if (!slot.CanWriteTo(SAV))
return SlotTouchResult.FailDelete;
if (!DeleteSlot(slot))
var pk = SAV.BlankPKM;
var settings = EntityImportSettings.None;
using var change = Changelog.Begin(slot);
if (!slot.WriteTo(SAV, pk, settings))
return SlotTouchResult.FailDelete;
change.Commit();
NotifySlotChanged(slot, SlotTouchType.Delete, pk);
return SlotTouchResult.Success;
}
/// <summary>
/// Swaps two slots.
/// Swaps two slots as one undoable operation.
/// </summary>
/// <param name="source">Source slot to be switched with <see cref="dest"/>.</param>
/// <param name="dest">Destination slot to be switched with <see cref="source"/>.</param>
@@ -69,49 +94,93 @@ public SlotTouchResult Swap(ISlotInfo source, ISlotInfo dest)
return SlotTouchResult.FailDestination;
var settings = EntityImportSettings.None;
var s = source.Read(SAV);
var d = dest.Read(SAV);
WriteSlot(source, s, SlotTouchType.None, settings);
WriteSlot(dest, d, SlotTouchType.Swap, settings);
var sourcePK = source.Read(SAV);
var destPK = dest.Read(SAV);
using var change = Changelog.Begin([source, dest]);
if (!source.WriteTo(SAV, destPK, settings))
return SlotTouchResult.FailSource;
if (!dest.WriteTo(SAV, sourcePK, settings))
return SlotTouchResult.FailDestination;
change.Commit();
NotifySlotChanged(source, SlotTouchType.Swap, destPK);
NotifySlotChanged(dest, SlotTouchType.Swap, sourcePK);
return SlotTouchResult.Success;
}
private bool WriteSlot(ISlotInfo slot, PKM pk, SlotTouchType type = SlotTouchType.Set, EntityImportSettings setDetail = default)
/// <summary>
/// Performs a batch operation against multiple slots as one undoable operation.
/// </summary>
/// <param name="slots">Slots affected by the operation.</param>
/// <param name="action">
/// Action which performs the actual modifications. The slots have already
/// been captured by the changelog when this action executes.
/// </param>
public bool Batch(IEnumerable<ISlotInfo> slots, Action<IReadOnlyList<ISlotInfo>> action)
{
Changelog.AddNewChange(slot);
var result = slot.WriteTo(SAV, pk, setDetail);
if (result)
NotifySlotChanged(slot, type, pk);
return result;
}
var affected = slots.ToArray();
private bool DeleteSlot(ISlotInfo slot)
{
var pk = SAV.BlankPKM;
var settings = EntityImportSettings.None;
return WriteSlot(slot, pk, SlotTouchType.Delete, settings);
if (affected.Length == 0)
return false;
foreach (var slot in affected)
{
if (!slot.CanWriteTo(SAV))
return false;
}
using var change = Changelog.Begin(affected);
action(affected);
// Disposing `change` rolls back the captured state, even if the action throws.
change.Commit();
foreach (var slot in affected)
{
var pk = slot.Read(SAV);
NotifySlotChanged(slot, SlotTouchType.Set, pk);
}
return true;
}
/// <summary>
/// Undo the last change made to a slot.
/// Undoes the last change and notifies every affected slot.
/// </summary>
public void Undo()
{
if (!Changelog.CanUndo)
return;
var slot = Changelog.Undo();
NotifySlotChanged(slot, SlotTouchType.Delete, slot.Read(SAV));
var slots = Changelog.Undo();
foreach (var slot in slots)
{
var pk = slot.Read(SAV);
NotifySlotChanged(slot, SlotTouchType.Undo, pk);
}
}
/// <summary>
/// Redo the last undone change made to a slot.
/// Redoes the last undone change and notifies every affected slot.
/// </summary>
public void Redo()
{
if (!Changelog.CanRedo)
return;
var slot = Changelog.Redo();
NotifySlotChanged(slot, SlotTouchType.Delete, slot.Read(SAV));
var slots = Changelog.Redo();
foreach (var slot in slots)
{
var pk = slot.Read(SAV);
NotifySlotChanged(slot, SlotTouchType.Redo, pk);
}
}
}

View File

@@ -16,6 +16,10 @@ public enum SlotTouchType
Delete,
/// <summary> Data swap/move request </summary>
Swap,
/// <summary> Un-doing a previous modification </summary>
Undo,
/// <summary> Re-doing a modification that was previously un-done </summary>
Redo,
/// <summary> Request to be handled via external logic (atypical) </summary>
External,

View File

@@ -21,6 +21,8 @@ public static class SlotUtil
SlotTouchType.Set => SpriteUtil.Spriter.Set,
SlotTouchType.Delete => SpriteUtil.Spriter.Delete,
SlotTouchType.Swap => SpriteUtil.Spriter.Set,
SlotTouchType.Undo => SpriteUtil.Spriter.Delete,
SlotTouchType.Redo => SpriteUtil.Spriter.Set,
_ => throw new ArgumentOutOfRangeException(nameof(type), type, null),
};

View File

@@ -431,10 +431,13 @@ private void MainMenuBoxDumpSingle(object sender, EventArgs e)
private void MainMenuBatchEditor(object sender, EventArgs e)
{
using var form = new BatchEditor(PKME_Tabs.PreparePKM(), C_SAV.SAV);
form.ShowDialog();
C_SAV.SetPKMBoxes(); // refresh
C_SAV.UpdateBoxViewers();
using var form = new BatchEditor(PKME_Tabs.PreparePKM(), C_SAV.SAV, C_SAV.EditEnv.Slots.Changelog);
if (form.ShowDialog() != DialogResult.OK)
return;
foreach (var slot in form.GetModifiedSlots())
C_SAV.EditEnv.Slots.UpdateSlot(slot);
C_SAV.UpdateUndoRedo();
}
private void MainMenuFolder(object sender, EventArgs e)

View File

@@ -13,9 +13,12 @@ partial class BatchEditor
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
if (disposing)
{
components.Dispose();
_filterCountCancellation?.Cancel();
_filterCountCancellation?.Dispose();
if (components != null)
components.Dispose();
}
base.Dispose(disposing);
}
@@ -34,11 +37,15 @@ private void InitializeComponent()
RB_Party = new System.Windows.Forms.RadioButton();
TB_Folder = new System.Windows.Forms.TextBox();
RTB_Instructions = new System.Windows.Forms.RichTextBox();
B_Go = new System.Windows.Forms.Button();
PB_Show = new System.Windows.Forms.ProgressBar();
B_Run = new System.Windows.Forms.Button();
B_Reset = new System.Windows.Forms.Button();
B_Cancel = new System.Windows.Forms.Button();
B_Save = new System.Windows.Forms.Button();
B_Add = new System.Windows.Forms.Button();
b = new System.ComponentModel.BackgroundWorker();
TLP_Bottom = new System.Windows.Forms.TableLayoutPanel();
L_Count = new System.Windows.Forms.Label();
FLP_RB.SuspendLayout();
TLP_Bottom.SuspendLayout();
SuspendLayout();
//
// RB_Boxes
@@ -47,7 +54,7 @@ private void InitializeComponent()
RB_Boxes.Appearance = System.Windows.Forms.Appearance.Button;
RB_Boxes.AutoSize = true;
RB_Boxes.Checked = true;
RB_Boxes.Location = new System.Drawing.Point(0, 1);
RB_Boxes.Location = new System.Drawing.Point(0, 0);
RB_Boxes.Margin = new System.Windows.Forms.Padding(0);
RB_Boxes.Name = "RB_Boxes";
RB_Boxes.Size = new System.Drawing.Size(52, 27);
@@ -62,7 +69,7 @@ private void InitializeComponent()
RB_Path.Anchor = System.Windows.Forms.AnchorStyles.Left;
RB_Path.Appearance = System.Windows.Forms.Appearance.Button;
RB_Path.AutoSize = true;
RB_Path.Location = new System.Drawing.Point(99, 1);
RB_Path.Location = new System.Drawing.Point(99, 0);
RB_Path.Margin = new System.Windows.Forms.Padding(0);
RB_Path.Name = "RB_Path";
RB_Path.Size = new System.Drawing.Size(64, 27);
@@ -73,15 +80,16 @@ private void InitializeComponent()
//
// FLP_RB
//
FLP_RB.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
TLP_Bottom.SetColumnSpan(FLP_RB, 5);
FLP_RB.Controls.Add(RB_Boxes);
FLP_RB.Controls.Add(RB_Party);
FLP_RB.Controls.Add(RB_Path);
FLP_RB.Controls.Add(TB_Folder);
FLP_RB.Location = new System.Drawing.Point(12, 12);
FLP_RB.Margin = new System.Windows.Forms.Padding(4);
FLP_RB.Dock = System.Windows.Forms.DockStyle.Fill;
FLP_RB.Location = new System.Drawing.Point(4, 4);
FLP_RB.Margin = new System.Windows.Forms.Padding(4, 4, 4, 0);
FLP_RB.Name = "FLP_RB";
FLP_RB.Size = new System.Drawing.Size(460, 28);
FLP_RB.Size = new System.Drawing.Size(626, 28);
FLP_RB.TabIndex = 2;
//
// RB_Party
@@ -89,92 +97,162 @@ private void InitializeComponent()
RB_Party.Anchor = System.Windows.Forms.AnchorStyles.Left;
RB_Party.Appearance = System.Windows.Forms.Appearance.Button;
RB_Party.AutoSize = true;
RB_Party.Location = new System.Drawing.Point(52, 1);
RB_Party.Location = new System.Drawing.Point(52, 0);
RB_Party.Margin = new System.Windows.Forms.Padding(0);
RB_Party.Name = "RB_Party";
RB_Party.Size = new System.Drawing.Size(47, 27);
RB_Party.TabIndex = 1;
RB_Party.Text = "Party";
RB_Party.UseVisualStyleBackColor = true;
RB_Party.Click += B_SAV_Click;
//
// TB_Folder
//
TB_Folder.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
TB_Folder.Location = new System.Drawing.Point(165, 2);
TB_Folder.Dock = System.Windows.Forms.DockStyle.Fill;
TB_Folder.Location = new System.Drawing.Point(2, 29);
TB_Folder.Margin = new System.Windows.Forms.Padding(2);
TB_Folder.Name = "TB_Folder";
TB_Folder.ReadOnly = true;
TB_Folder.Size = new System.Drawing.Size(292, 25);
TB_Folder.Size = new System.Drawing.Size(465, 25);
TB_Folder.TabIndex = 3;
TB_Folder.Visible = false;
//
// RTB_Instructions
//
RTB_Instructions.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
RTB_Instructions.Location = new System.Drawing.Point(12, 98);
RTB_Instructions.Margin = new System.Windows.Forms.Padding(4);
TLP_Bottom.SetColumnSpan(RTB_Instructions, 5);
RTB_Instructions.Location = new System.Drawing.Point(4, 88);
RTB_Instructions.Margin = new System.Windows.Forms.Padding(4, 0, 4, 4);
RTB_Instructions.Name = "RTB_Instructions";
RTB_Instructions.Size = new System.Drawing.Size(459, 177);
RTB_Instructions.Size = new System.Drawing.Size(626, 219);
RTB_Instructions.TabIndex = 5;
RTB_Instructions.Text = "";
RTB_Instructions.TextChanged += RTB_Instructions_TextChanged;
//
// B_Go
// B_Run
//
B_Go.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
B_Go.Location = new System.Drawing.Point(405, 282);
B_Go.Margin = new System.Windows.Forms.Padding(4);
B_Go.Name = "B_Go";
B_Go.Size = new System.Drawing.Size(66, 28);
B_Go.TabIndex = 6;
B_Go.Text = "Run";
B_Go.UseVisualStyleBackColor = true;
B_Go.Click += B_Go_Click;
B_Run.Dock = System.Windows.Forms.DockStyle.Fill;
B_Run.Location = new System.Drawing.Point(92, 315);
B_Run.Margin = new System.Windows.Forms.Padding(4);
B_Run.Name = "B_Run";
B_Run.Size = new System.Drawing.Size(80, 32);
B_Run.TabIndex = 9;
B_Run.Text = "Run";
B_Run.UseVisualStyleBackColor = true;
B_Run.Click += B_Run_Click;
//
// PB_Show
// B_Reset
//
PB_Show.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
PB_Show.Location = new System.Drawing.Point(12, 284);
PB_Show.Margin = new System.Windows.Forms.Padding(4);
PB_Show.Name = "PB_Show";
PB_Show.Size = new System.Drawing.Size(382, 24);
PB_Show.TabIndex = 7;
B_Reset.Dock = System.Windows.Forms.DockStyle.Fill;
B_Reset.Enabled = false;
B_Reset.Location = new System.Drawing.Point(4, 315);
B_Reset.Margin = new System.Windows.Forms.Padding(4);
B_Reset.Name = "B_Reset";
B_Reset.Size = new System.Drawing.Size(80, 32);
B_Reset.TabIndex = 8;
B_Reset.Text = "Reset";
B_Reset.UseVisualStyleBackColor = true;
B_Reset.Click += B_Reset_Click;
//
// B_Cancel
//
B_Cancel.AutoSize = true;
B_Cancel.DialogResult = System.Windows.Forms.DialogResult.Cancel;
B_Cancel.Dock = System.Windows.Forms.DockStyle.Fill;
B_Cancel.Location = new System.Drawing.Point(462, 315);
B_Cancel.Margin = new System.Windows.Forms.Padding(4);
B_Cancel.Name = "B_Cancel";
B_Cancel.Size = new System.Drawing.Size(80, 32);
B_Cancel.TabIndex = 10;
B_Cancel.Text = "Cancel";
B_Cancel.UseVisualStyleBackColor = true;
B_Cancel.Click += B_Cancel_Click;
//
// B_Save
//
B_Save.AutoSize = true;
B_Save.Dock = System.Windows.Forms.DockStyle.Fill;
B_Save.Location = new System.Drawing.Point(550, 315);
B_Save.Margin = new System.Windows.Forms.Padding(4);
B_Save.Name = "B_Save";
B_Save.Size = new System.Drawing.Size(80, 32);
B_Save.TabIndex = 11;
B_Save.Text = "Save";
B_Save.UseVisualStyleBackColor = true;
B_Save.Click += B_Save_Click;
//
// B_Add
//
B_Add.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
B_Add.Location = new System.Drawing.Point(406, 42);
B_Add.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
B_Add.Dock = System.Windows.Forms.DockStyle.Fill;
B_Add.Location = new System.Drawing.Point(550, 36);
B_Add.Margin = new System.Windows.Forms.Padding(4);
B_Add.Name = "B_Add";
B_Add.Size = new System.Drawing.Size(66, 28);
B_Add.Size = new System.Drawing.Size(80, 48);
B_Add.TabIndex = 4;
B_Add.Text = "Add";
B_Add.UseVisualStyleBackColor = true;
B_Add.Click += B_Add_Click;
//
// b
// TLP_Bottom
//
b.WorkerReportsProgress = true;
TLP_Bottom.ColumnCount = 5;
TLP_Bottom.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Absolute, 88F));
TLP_Bottom.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Absolute, 88F));
TLP_Bottom.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
TLP_Bottom.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Absolute, 88F));
TLP_Bottom.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Absolute, 88F));
TLP_Bottom.Controls.Add(L_Count, 2, 3);
TLP_Bottom.Controls.Add(B_Add, 4, 1);
TLP_Bottom.Controls.Add(B_Save, 4, 3);
TLP_Bottom.Controls.Add(FLP_RB, 0, 0);
TLP_Bottom.Controls.Add(RTB_Instructions, 0, 2);
TLP_Bottom.Controls.Add(B_Reset, 0, 3);
TLP_Bottom.Controls.Add(B_Run, 1, 3);
TLP_Bottom.Controls.Add(B_Cancel, 3, 3);
TLP_Bottom.Dock = System.Windows.Forms.DockStyle.Fill;
TLP_Bottom.Location = new System.Drawing.Point(0, 0);
TLP_Bottom.Margin = new System.Windows.Forms.Padding(4);
TLP_Bottom.Name = "TLP_Bottom";
TLP_Bottom.RowCount = 4;
TLP_Bottom.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Absolute, 32F));
TLP_Bottom.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Absolute, 56F));
TLP_Bottom.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F));
TLP_Bottom.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Absolute, 40F));
TLP_Bottom.Size = new System.Drawing.Size(634, 351);
TLP_Bottom.TabIndex = 12;
//
// L_Count
//
L_Count.Anchor = System.Windows.Forms.AnchorStyles.None;
L_Count.AutoSize = true;
L_Count.Location = new System.Drawing.Point(261, 322);
L_Count.Name = "L_Count";
L_Count.Size = new System.Drawing.Size(112, 17);
L_Count.TabIndex = 12;
L_Count.Text = "Matching: {0} / {1}";
//
// BatchEditor
//
AllowDrop = true;
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Inherit;
ClientSize = new System.Drawing.Size(484, 321);
Controls.Add(B_Add);
Controls.Add(PB_Show);
Controls.Add(B_Go);
Controls.Add(RTB_Instructions);
Controls.Add(FLP_RB);
CancelButton = B_Cancel;
ClientSize = new System.Drawing.Size(634, 351);
Controls.Add(TLP_Bottom);
FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedSingle;
Icon = Properties.Resources.Icon;
Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
MaximizeBox = false;
MinimumSize = new System.Drawing.Size(500, 340);
MinimumSize = new System.Drawing.Size(650, 390);
Name = "BatchEditor";
StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
Text = "Batch Editor";
FormClosing += BatchEditor_FormClosing;
DragDrop += TabMain_DragDrop;
DragEnter += TabMain_DragEnter;
FLP_RB.ResumeLayout(false);
FLP_RB.PerformLayout();
TLP_Bottom.ResumeLayout(false);
TLP_Bottom.PerformLayout();
ResumeLayout(false);
}
@@ -185,10 +263,13 @@ private void InitializeComponent()
private System.Windows.Forms.FlowLayoutPanel FLP_RB;
private System.Windows.Forms.TextBox TB_Folder;
private System.Windows.Forms.RichTextBox RTB_Instructions;
private System.Windows.Forms.Button B_Go;
private System.Windows.Forms.ProgressBar PB_Show;
private System.Windows.Forms.Button B_Run;
private System.Windows.Forms.Button B_Reset;
private System.Windows.Forms.Button B_Cancel;
private System.Windows.Forms.Button B_Save;
private System.Windows.Forms.Button B_Add;
private System.Windows.Forms.RadioButton RB_Party;
private System.ComponentModel.BackgroundWorker b;
private System.Windows.Forms.TableLayoutPanel TLP_Bottom;
private System.Windows.Forms.Label L_Count;
}
}

View File

@@ -1,7 +1,10 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using PKHeX.Core;
using PKHeX.WinForms.Controls;
@@ -11,60 +14,256 @@ namespace PKHeX.WinForms;
public partial class BatchEditor : Form
{
private readonly SaveFile SAV;
private readonly SaveFile _sav;
private readonly SlotChangelog _changelog;
// Mass Editing
private EntityBatchProcessor editor = new();
private readonly EntityInstructionBuilder UC_Builder;
// Cached source data. The cache is intentionally mutable; batch edits are accumulated here until the user chooses Save.
private IReadOnlyList<SlotCache>? _boxData;
private IReadOnlyList<SlotCache>? _party;
private IReadOnlyList<SlotCache>? _folder;
private readonly Dictionary<ISlotInfo, string> _folderPaths = new();
private readonly HashSet<ISlotInfo> _modifiedSlots = [];
private readonly string _matchingCountFormat;
private static string LastUsedCommands = string.Empty;
private EntityBatchProcessor _editor = new();
private readonly EntityInstructionBuilder _builder;
public BatchEditor(PKM pk, SaveFile sav)
/// <summary>
/// Remember the last used commands so that they can be restored when the form is reopened.
/// </summary>
private static string _lastUsedCommands = string.Empty;
public BatchEditor(PKM pk, SaveFile sav, SlotChangelog changelog)
{
InitializeComponent();
WinFormsUtil.TranslateInterface(this, Main.CurrentLanguage);
var above = FLP_RB.Location;
UC_Builder = new EntityInstructionBuilder(() => pk)
{
Location = new() { Y = above.Y + FLP_RB.Height + 4 - 1, X = above.X + 1 },
Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right,
Width = B_Add.Location.X - above.X - 2,
};
Controls.Add(UC_Builder);
SAV = sav;
DragDrop += TabMain_DragDrop;
DragEnter += TabMain_DragEnter;
_matchingCountFormat = L_Count.Text; // cache the translated string
_sav = sav;
_changelog = changelog;
RTB_Instructions.Text = LastUsedCommands;
FormClosing += (_, _) => LastUsedCommands = RTB_Instructions.Text;
// Builder needs to be late-bound to the input PKM from the main form.
_builder = new EntityInstructionBuilder(() => pk) { Dock = DockStyle.Fill, Margin = new Padding(4) };
TLP_Bottom.Controls.Add(_builder, 0, 1);
TLP_Bottom.SetColumnSpan(_builder, TLP_Bottom.ColumnCount - 1); // Add button occupies last column.
// Boxes are the default source and are immediately available for filter analysis.
_boxData = CreateBoxData();
UpdateFilterCountDebounced();
UpdateButtons();
RTB_Instructions.Text = _lastUsedCommands;
}
public IReadOnlyList<ISlotInfo> GetModifiedSlots() => [.. _modifiedSlots];
private IReadOnlyList<SlotCache> CreateBoxData()
{
var data = new List<SlotCache>(_sav.SlotCount);
SlotInfoLoader.AddBoxData(_sav, data);
return data;
}
private IReadOnlyList<SlotCache> CreatePartyData()
{
var data = new List<SlotCache>(_sav.PartyCount);
SlotInfoLoader.AddPartyData(_sav, data);
return data;
}
private IReadOnlyList<SlotCache> CreateFolderData()
{
if (!Directory.Exists(TB_Folder.Text))
return [];
var result = new List<SlotCache>();
IEnumerable<string> files;
try
{
files = Directory.GetFiles(TB_Folder.Text, "*", SearchOption.AllDirectories);
}
catch (IOException)
{
return result;
}
catch (UnauthorizedAccessException)
{
return result;
}
foreach (var source in files)
{
var fi = new FileInfo(source);
if (!EntityDetection.IsSizePlausible(fi.Length))
continue;
try
{
var data = File.ReadAllBytes(source);
if (FileUtil.TryGetPKM(data, out var pk, fi.Extension, _sav))
{
var info = new SlotInfoFileSingle(source);
result.Add(new SlotCache(info, pk));
_folderPaths[info] = source;
}
}
catch (IOException)
{
// A file that cannot be read is simply not a processable source entity.
}
catch (UnauthorizedAccessException)
{
// A file that cannot be read is simply not a processable source entity.
}
}
return result;
}
private IReadOnlyList<SlotCache> GetCurrentData()
{
if (RB_Party.Checked)
return _party ??= CreatePartyData();
if (RB_Path.Checked)
return _folder ??= CreateFolderData();
return _boxData!;
}
private void B_Open_Click(object sender, EventArgs e)
{
if (!B_Go.Enabled)
return;
using var fbd = new FolderBrowserDialog();
if (fbd.ShowDialog() != DialogResult.OK)
return;
TB_Folder.Text = fbd.SelectedPath;
TB_Folder.Visible = true;
RB_Path.Checked = true;
_folder = null;
_folderPaths.Clear();
UpdateFilterCountDebounced();
UpdateButtons();
}
private void B_SAV_Click(object sender, EventArgs e)
{
TB_Folder.Text = string.Empty;
TB_Folder.Visible = false;
_folder = null;
_folderPaths.Clear();
UpdateFilterCountDebounced();
UpdateButtons();
}
private void B_Go_Click(object sender, EventArgs e)
private void B_Reset_Click(object sender, EventArgs e)
{
RunBackgroundWorker();
// Reset only discards the in-memory save-file work. Folder operations have already
// been written to disk and intentionally cannot be reverted by this form.
_modifiedSlots.Clear();
_boxData = null;
_party = null;
_folder = null;
_folderPaths.Clear();
_editor = new EntityBatchProcessor();
RB_Boxes.Checked = true;
TB_Folder.Text = string.Empty;
TB_Folder.Visible = false;
_boxData = CreateBoxData();
UpdateFilterCountDebounced();
UpdateButtons();
}
private void B_Run_Click(object sender, EventArgs e)
{
ReadOnlySpan<char> text = RTB_Instructions.Text;
if (!TryGetInstructionSets(text, out var sets, promptForEmptyValues: true, showErrors: true))
return;
foreach (var set in sets)
{
EntityBatchEditor.ScreenStrings(set.Filters);
EntityBatchEditor.ScreenStrings(set.Instructions);
}
if (RB_Path.Checked)
{
RunBatchEditFolder(sets);
return;
}
RunBatchEditSaveFile(sets);
}
private void B_Save_Click(object sender, EventArgs e)
{
if (_modifiedSlots.Count == 0)
{
DialogResult = DialogResult.OK;
return;
}
var slots = GetChangelogSlots();
using var change = _changelog.Begin(slots);
foreach (var slot in slots)
{
if (TryGetCachedSlot(slot, out var cache))
slot.WriteTo(_sav, cache.Entity, EntityImportSettings.None);
}
change.Commit();
DialogResult = DialogResult.OK;
}
private IReadOnlyList<ISlotInfo> GetChangelogSlots()
{
// Party reversion captures the entire party, so multiple party slot entries only need
// one changelog slot. Box entries remain individually addressable.
var slots = _modifiedSlots.Where(z => z is not SlotInfoParty).ToList();
if (_modifiedSlots.Any(z => z is SlotInfoParty))
slots.Add(_modifiedSlots.First(z => z is SlotInfoParty));
return slots;
}
private bool TryGetCachedSlot(ISlotInfo source, out SlotCache cache)
{
if (_boxData is not null)
{
foreach (var slot in _boxData)
{
if (ReferenceEquals(slot.Source, source))
{
cache = slot;
return true;
}
}
}
if (_party is not null)
{
foreach (var slot in _party)
{
if (!ReferenceEquals(slot.Source, source))
continue;
cache = slot;
return true;
}
}
cache = null!;
return false;
}
private void B_Cancel_Click(object sender, EventArgs e)
{
DialogResult = DialogResult.Cancel;
Close();
}
private void B_Add_Click(object sender, EventArgs e)
{
var s = UC_Builder.Create();
var s = _builder.Create();
if (s.Length == 0)
{ WinFormsUtil.Alert(MsgBEPropertyInvalid); return; }
@@ -76,17 +275,17 @@ private void B_Add_Click(object sender, EventArgs e)
RTB_Instructions.AppendText(s);
}
private static void TabMain_DragEnter(object? sender, DragEventArgs? e)
private void TabMain_DragEnter(object? sender, DragEventArgs e)
{
if (e?.Data is null)
if (e.Data is null)
return;
if (e.Data.GetDataPresent(DataFormats.FileDrop))
e.Effect = DragDropEffects.Copy;
}
private void TabMain_DragDrop(object? sender, DragEventArgs? e)
private void TabMain_DragDrop(object? sender, DragEventArgs e)
{
if (e?.Data?.GetData(DataFormats.FileDrop) is not string[] { Length: not 0 } files)
if (e.Data?.GetData(DataFormats.FileDrop) is not string[] { Length: not 0 } files)
return;
if (!Directory.Exists(files[0]))
return;
@@ -95,199 +294,251 @@ private void TabMain_DragDrop(object? sender, DragEventArgs? e)
TB_Folder.Visible = true;
RB_Boxes.Checked = RB_Party.Checked = false;
RB_Path.Checked = true;
_folder = null;
_folderPaths.Clear();
UpdateFilterCountDebounced();
UpdateButtons();
}
private void RunBackgroundWorker()
private void RTB_Instructions_TextChanged(object? sender, EventArgs e) => UpdateFilterCountDebounced();
private CancellationTokenSource _filterCountCancellation = new();
private int _filterCountGeneration;
private async void UpdateFilterCountDebounced()
{
ReadOnlySpan<char> text = RTB_Instructions.Text;
try
{
var text = RTB_Instructions.Text;
await (_filterCountCancellation.CancelAsync());
_filterCountCancellation.Dispose();
var cancellation = new CancellationTokenSource();
_filterCountCancellation = cancellation;
var generation = ++_filterCountGeneration;
await Task.Delay(250, cancellation.Token);
if (cancellation.IsCancellationRequested)
return;
var result = await Task.Run(() => TryGetFilterMessage(text, cancellation.Token, out var message)
? message
: null, cancellation.Token); // return to GUI thread
if (cancellation.IsCancellationRequested)
return;
if (generation != _filterCountGeneration)
return;
L_Count.Text = result;
}
catch
{
// Don't care.
}
}
private bool TryGetFilterMessage(ReadOnlySpan<char> text, CancellationToken token, [NotNullWhen(true)] out string? result)
{
result = null;
var data = GetCurrentData();
int total = data.Count(z => z.Entity.Species != 0);
if (total == 0)
{
result = string.Format(_matchingCountFormat, 0, 0);
return true;
}
if (!TryGetInstructionSets(text, out var sets, promptForEmptyValues: false, allowOnlyFilters: true))
{
result = string.Format(_matchingCountFormat, "-", total);
return true;
}
foreach (var set in sets)
EntityBatchEditor.ScreenStrings(set.Filters);
if (token.IsCancellationRequested)
return false;
int matched = 0;
var max = _sav.MaxSpeciesID;
foreach (var entry in data)
{
var pk = entry.Entity;
if (pk.Species == 0 || pk.Species > max)
continue;
if (entry.Source is SlotInfoBox info && _sav.GetBoxSlotFlags(info.Box, info.Slot).IsOverwriteProtected())
continue;
if (token.IsCancellationRequested)
return false;
if (sets.Any(set => IsFilterMatch(entry, set)))
matched++;
}
result = string.Format(_matchingCountFormat, matched, total);
return true;
}
private static bool IsFilterMatch(SlotCache entry, StringInstructionSet set)
{
var filterMeta = set.Filters.Where(IsMetaFilter).ToArray();
var filters = set.Filters.Where(z => !IsMetaFilter(z)).ToArray();
if (!EntityBatchEditor.IsFilterMatchMeta(filterMeta, entry))
return false;
return filters.Length == 0 || BatchEditingUtil.IsFilterMatch(filters, entry.Entity);
}
private static bool IsMetaFilter(StringInstruction filter) => BatchFilters.FilterMeta.Any(z => z.IsMatch(filter.PropertyName));
private static bool TryGetInstructionSets(ReadOnlySpan<char> text, out StringInstructionSet[] sets, bool promptForEmptyValues, bool showErrors = false, bool allowOnlyFilters = false)
{
sets = [];
if (text.IsEmpty)
return false;
if (StringInstructionSet.HasEmptyLine(text))
{ WinFormsUtil.Error(MsgBEInstructionInvalid); return; }
{
if (showErrors)
WinFormsUtil.Error(MsgBEInstructionInvalid);
return false;
}
try
{
sets = StringInstructionSet.GetBatchSets(text);
}
catch
{
if (showErrors)
WinFormsUtil.Error(MsgBEInstructionInvalid);
return false;
}
var sets = StringInstructionSet.GetBatchSets(text);
if (Array.Exists(sets, s => s.Filters.Any(z => string.IsNullOrWhiteSpace(z.PropertyValue))))
{ WinFormsUtil.Error(MsgBEFilterEmpty); return; }
{
if (showErrors)
WinFormsUtil.Error(MsgBEFilterEmpty);
return false;
}
if (Array.Exists(sets, z => z.Instructions.Count == 0))
{ WinFormsUtil.Error(MsgBEInstructionNone); return; }
{
if (showErrors)
WinFormsUtil.Error(MsgBEInstructionNone);
return (allowOnlyFilters && sets.Any(z => z.Filters.Count != 0));
}
if (!promptForEmptyValues)
return true;
var emptyVal = sets.SelectMany(s => s.Instructions.Where(z => string.IsNullOrWhiteSpace(z.PropertyValue))).ToArray();
if (emptyVal.Length != 0)
{
string props = string.Join(", ", emptyVal.Select(z => z.PropertyName));
string invalid = MsgBEPropertyEmpty + Environment.NewLine + props;
if (DialogResult.Yes != WinFormsUtil.Prompt(MessageBoxButtons.YesNo, invalid, MsgContinue))
return;
}
if (emptyVal.Length == 0)
return true;
string? destPath = null;
if (RB_Path.Checked)
{
WinFormsUtil.Alert(MsgExportFolder, MsgExportFolderAdvice);
using var fbd = new FolderBrowserDialog();
var dr = fbd.ShowDialog();
if (dr != DialogResult.OK)
return;
destPath = fbd.SelectedPath;
}
FLP_RB.Enabled = RTB_Instructions.Enabled = B_Go.Enabled = false;
foreach (var set in sets)
{
EntityBatchEditor.ScreenStrings(set.Filters);
EntityBatchEditor.ScreenStrings(set.Instructions);
}
RunBatchEdit(sets, TB_Folder.Text, destPath);
string props = string.Join(", ", emptyVal.Select(z => z.PropertyName));
string invalid = MsgBEPropertyEmpty + Environment.NewLine + props;
return DialogResult.Yes == WinFormsUtil.Prompt(MessageBoxButtons.YesNo, invalid, MsgContinue);
}
private void RunBatchEdit(StringInstructionSet[] sets, string source, string? destination)
{
editor = new EntityBatchProcessor();
bool finished = false, displayed = false; // hack cuz DoWork event isn't cleared after completion
b.DoWork += (_, _) =>
{
if (finished)
return;
if (RB_Boxes.Checked)
RunBatchEditSaveFile(sets, boxes: true);
else if (RB_Party.Checked)
RunBatchEditSaveFile(sets, party: true);
else if (destination is not null)
RunBatchEditFolder(sets, source, destination);
finished = true;
};
b.ProgressChanged += (_, e) => SetProgressBar(e.ProgressPercentage);
b.RunWorkerCompleted += (_, _) =>
{
string result = editor.GetEditorResults(sets);
if (!displayed) WinFormsUtil.Alert(result);
displayed = true;
FLP_RB.Enabled = RTB_Instructions.Enabled = B_Go.Enabled = true;
SetupProgressBar(0);
};
b.RunWorkerAsync();
}
private void RunBatchEditFolder(IReadOnlyCollection<StringInstructionSet> sets, string source, string destination)
{
var files = Directory.GetFiles(source, "*", SearchOption.AllDirectories);
SetupProgressBar(files.Length * sets.Count);
foreach (var set in sets)
ProcessFolder(files, destination, set.Filters, set.Instructions);
}
private void RunBatchEditSaveFile(IReadOnlyCollection<StringInstructionSet> sets, bool boxes = false, bool party = false)
{
if (party)
{
var data = new List<SlotCache>(SAV.PartyCount);
SlotInfoLoader.AddPartyData(SAV, data);
process(data);
foreach (var slot in data)
slot.Source.WriteTo(SAV, slot.Entity, EntityImportSettings.None);
}
if (boxes)
{
var data = new List<SlotCache>(SAV.SlotCount);
SlotInfoLoader.AddBoxData(SAV, data);
process(data);
foreach (var slot in data)
slot.Source.WriteTo(SAV, slot.Entity, EntityImportSettings.None);
}
void process(IList<SlotCache> d)
{
SetupProgressBar(d.Count * sets.Count);
foreach (var set in sets)
ProcessSAV(d, set.Filters, set.Instructions);
}
}
// Progress Bar
private void SetupProgressBar(int count) => PB_Show.BeginInvoke(() =>
{
PB_Show.Minimum = 0;
PB_Show.Step = 1;
PB_Show.Value = 0;
PB_Show.Maximum = count;
});
private void SetProgressBar(int position) => PB_Show.BeginInvoke(() => PB_Show.Value = position);
private void ProcessSAV(IList<SlotCache> data, IReadOnlyList<StringInstruction> Filters, IReadOnlyList<StringInstruction> Instructions)
private void RunBatchEditSaveFile(IReadOnlyCollection<StringInstructionSet> sets)
{
var data = GetCurrentData();
if (data.Count == 0)
return;
// Pull out any filter meta instructions from the filters.
var filterMeta = Filters.Where(f => BatchFilters.FilterMeta.Any(z => z.IsMatch(f.PropertyName))).ToArray();
_editor = new EntityBatchProcessor();
foreach (var set in sets)
ProcessSAV(data, set.Filters, set.Instructions);
UpdateFilterCountDebounced();
UpdateButtons();
string result = _editor.GetEditorResults(sets);
WinFormsUtil.Alert(result);
}
private void ProcessSAV(IReadOnlyList<SlotCache> data, IReadOnlyList<StringInstruction> filters, IReadOnlyList<StringInstruction> instructions)
{
var filterMeta = filters.Where(IsMetaFilter).ToArray();
if (filterMeta.Length != 0)
Filters = Filters.Except(filterMeta).ToArray();
filters = [.. filters.Where(z => !IsMetaFilter(z))];
var max = SAV.MaxSpeciesID;
for (int i = 0; i < data.Count; i++)
var max = _sav.MaxSpeciesID;
foreach (var entry in data)
{
var entry = data[i];
var pk = entry.Entity;
// Ignore empty/invalid slots.
var spec = pk.Species;
if (spec == 0 || spec > max)
{
b.ReportProgress(i);
continue;
}
if (entry.Source is SlotInfoBox info && SAV.GetBoxSlotFlags(info.Box, info.Slot).IsOverwriteProtected())
editor.AddSkipped();
else if (!EntityBatchEditor.IsFilterMatchMeta(filterMeta, entry))
editor.AddSkipped();
else
editor.Process(pk, Filters, Instructions);
if (entry.Source is SlotInfoBox info && _sav.GetBoxSlotFlags(info.Box, info.Slot).IsOverwriteProtected())
continue;
if (!EntityBatchEditor.IsFilterMatchMeta(filterMeta, entry))
continue;
b.ReportProgress(i);
if (_editor.Process(pk, filters, instructions))
_modifiedSlots.Add(entry.Source);
}
}
private void ProcessFolder(IReadOnlyList<string> files, string destDir, IReadOnlyList<StringInstruction> pkFilters, IReadOnlyList<StringInstruction> instructions)
private void RunBatchEditFolder(IReadOnlyCollection<StringInstructionSet> sets)
{
var filterMeta = pkFilters.Where(f => BatchFilters.FilterMeta.Any(z => z.IsMatch(f.PropertyName))).ToArray();
if (string.IsNullOrWhiteSpace(TB_Folder.Text))
return;
WinFormsUtil.Alert(MsgExportFolder, MsgExportFolderAdvice);
using var fbd = new FolderBrowserDialog();
if (fbd.ShowDialog() != DialogResult.OK)
return;
var data = GetCurrentData();
if (data.Count == 0)
return;
var destination = fbd.SelectedPath;
_editor = new EntityBatchProcessor();
foreach (var set in sets)
ProcessFolder(data, destination, set.Filters, set.Instructions);
string result = _editor.GetEditorResults(sets);
WinFormsUtil.Alert(result);
UpdateFilterCountDebounced();
}
private void ProcessFolder(IReadOnlyList<SlotCache> data, string destDir, IReadOnlyList<StringInstruction> pkFilters, IReadOnlyList<StringInstruction> instructions)
{
var filterMeta = pkFilters.Where(IsMetaFilter).ToArray();
if (filterMeta.Length != 0)
pkFilters = pkFilters.Except(filterMeta).ToArray();
pkFilters = [.. pkFilters.Where(z => !IsMetaFilter(z))];
for (int i = 0; i < files.Count; i++)
Span<byte> maxEntity = stackalloc byte[0x800]; // lol too big, futureproof for now
foreach (var entry in data)
{
TryProcess(files[i], destDir, filterMeta, pkFilters, instructions);
b.ReportProgress(i);
}
}
if (!EntityBatchEditor.IsFilterMatchMeta(filterMeta, entry))
continue;
private void TryProcess(string source, string destDir, IReadOnlyList<StringInstruction> metaFilters, IReadOnlyList<StringInstruction> pkFilters, IReadOnlyList<StringInstruction> instructions)
{
var fi = new FileInfo(source);
if (!EntityDetection.IsSizePlausible(fi.Length))
return;
if (!_editor.Process(entry.Entity, pkFilters, instructions))
continue;
byte[] data = File.ReadAllBytes(source);
_ = FileUtil.TryGetPKM(data, out var pk, fi.Extension, SAV);
if (pk is null)
return;
if (!_folderPaths.TryGetValue(entry.Source, out var source))
continue;
var info = new SlotInfoFileSingle(source);
var entry = new SlotCache(info, pk);
if (!EntityBatchEditor.IsFilterMatchMeta(metaFilters, entry))
{
editor.AddSkipped();
return;
}
if (editor.Process(pk, pkFilters, instructions))
{
Span<byte> result = stackalloc byte[pk.SIZE_PARTY];
pk.ForcePartyData();
pk.WriteDecryptedDataParty(result);
// We might have mixed size files, so we can't have a shared stackalloc
var result = maxEntity[..entry.Entity.SIZE_PARTY];
entry.Entity.ForcePartyData();
entry.Entity.WriteDecryptedDataParty(result);
File.WriteAllBytes(Path.Combine(destDir, Path.GetFileName(source)), result);
}
}
private void UpdateButtons()
{
bool isOperatingOnFolder = RB_Path.Checked;
B_Run.Enabled = RTB_Instructions.Text.Length != 0 && (isOperatingOnFolder || GetCurrentData().Count(z => z.Entity.Species != 0) != 0);
B_Save.Enabled = isOperatingOnFolder || _modifiedSlots.Count != 0;
B_Reset.Enabled = _modifiedSlots.Count != 0;
}
private void BatchEditor_FormClosing(object? sender, FormClosingEventArgs e) => _lastUsedCommands = RTB_Instructions.Text;
}

View File

@@ -116,7 +116,6 @@ private void InitializeComponent()
//
// SAV_DonutGenerator9a
//
AcceptButton = B_Generate;
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Inherit;
CancelButton = B_Cancel;
ClientSize = new System.Drawing.Size(536, 232);