diff --git a/PKHeX.Core/Editing/Saves/Slots/SlotChangelog.cs b/PKHeX.Core/Editing/Saves/Slots/SlotChangelog.cs index 9cd103fba..10635a051 100644 --- a/PKHeX.Core/Editing/Saves/Slots/SlotChangelog.cs +++ b/PKHeX.Core/Editing/Saves/Slots/SlotChangelog.cs @@ -1,76 +1,214 @@ +using System; using System.Collections.Generic; +using System.Linq; namespace PKHeX.Core; /// -/// Tracks slot changes and provides the ability to revert a change. +/// Maintains undo and redo history for changes made to a . /// -public sealed class SlotChangelog(SaveFile SAV) +public sealed record SlotChangelog(SaveFile Parent) { - private readonly Stack UndoStack = new(); - private readonly Stack RedoStack = new(); + private readonly Stack _undo = new(); + private readonly Stack _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) + /// + /// Begins tracking a change affecting one slot. + /// + public Change Begin(ISlotInfo info) => Begin([info]); + + /// + /// Begins tracking a change affecting multiple slots. + /// The resulting change is represented by a single undo/redo entry. + /// + public Change Begin(params IEnumerable slots) { - var revert = GetReversion(info, SAV); - AddUndo(revert); + var reversion = CreateReversion(slots, Parent); + return new Change(this, reversion); } - public ISlotInfo Undo() + /// + /// Undoes the most recent committed change. + /// + /// The slots affected by the change. + public IReadOnlyList 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() + /// + /// Redoes the most recently undone change. + /// + /// The slots affected by the change. + public IReadOnlyList 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 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) + /// + /// Represents a change being prepared for commit. + /// Capture the change before modifying the save, then call + /// after the modification succeeds. + /// + public sealed class Change(SlotChangelog owner, ISlotReversion slotReversion) : IDisposable { - internal readonly ISlotInfo Info = Info; - public abstract void Revert(SaveFile sav); + private bool _isCompleted; + + /// + /// Commits the change to the undo history. + /// + public void Commit() + { + if (_isCompleted) + throw new InvalidOperationException("The change has already been completed."); + + _isCompleted = true; + owner.Commit(slotReversion); + } + + /// + /// Restores the state captured when the change began and discards it. + /// + public void Rollback() + { + if (_isCompleted) + throw new InvalidOperationException("The change has already been completed."); + + _isCompleted = true; + slotReversion.Revert(owner.Parent); + owner.Discard(slotReversion); + } + + /// + /// Discards the change without restoring anything. + /// Use this when no mutation was performed. + /// + 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 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 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 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 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 party) => [.. party.Select(pk => pk.Clone())]; + } + + private sealed class CompositeSlotReversion : ISlotReversion + { + private readonly IReadOnlyList Reversions; + + public CompositeSlotReversion(params IReadOnlyList reversions) + { + if (reversions.Count == 0) + throw new ArgumentException("At least one reversion is required.", nameof(reversions)); + Reversions = reversions; + } + + public IReadOnlyList 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); + } } } diff --git a/PKHeX.Core/Editing/Saves/Slots/SlotEditor.cs b/PKHeX.Core/Editing/Saves/Slots/SlotEditor.cs index 8a7abb00e..db22ac4dc 100644 --- a/PKHeX.Core/Editing/Saves/Slots/SlotEditor.cs +++ b/PKHeX.Core/Editing/Saves/Slots/SlotEditor.cs @@ -1,3 +1,7 @@ +using System; +using System.Collections.Generic; +using System.Linq; + namespace PKHeX.Core; /// @@ -10,6 +14,15 @@ public sealed class SlotEditor(SaveFile SAV) private void NotifySlotChanged(ISlotInfo slot, SlotTouchType type, PKM pk) => Publisher.NotifySlotChanged(slot, type, pk); + /// + /// Notifies subscribers that a slot was modified externally. + /// + public void UpdateSlot(ISlotInfo slot) + { + var pk = slot.Read(SAV); + NotifySlotChanged(slot, SlotTouchType.Set, pk); + } + /// /// Gets data from a slot. /// @@ -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; } /// - /// Swaps two slots. + /// Swaps two slots as one undoable operation. /// /// Source slot to be switched with . /// Destination slot to be switched with . @@ -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) + /// + /// Performs a batch operation against multiple slots as one undoable operation. + /// + /// Slots affected by the operation. + /// + /// Action which performs the actual modifications. The slots have already + /// been captured by the changelog when this action executes. + /// + public bool Batch(IEnumerable slots, Action> 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; } /// - /// Undo the last change made to a slot. + /// Undoes the last change and notifies every affected slot. /// 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); + } } /// - /// Redo the last undone change made to a slot. + /// Redoes the last undone change and notifies every affected slot. /// 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); + } } } diff --git a/PKHeX.Core/Editing/Saves/Slots/SlotTouchType.cs b/PKHeX.Core/Editing/Saves/Slots/SlotTouchType.cs index c0190217c..c555f0a18 100644 --- a/PKHeX.Core/Editing/Saves/Slots/SlotTouchType.cs +++ b/PKHeX.Core/Editing/Saves/Slots/SlotTouchType.cs @@ -16,6 +16,10 @@ public enum SlotTouchType Delete, /// Data swap/move request Swap, + /// Un-doing a previous modification + Undo, + /// Re-doing a modification that was previously un-done + Redo, /// Request to be handled via external logic (atypical) External, diff --git a/PKHeX.WinForms/Controls/Slots/SlotUtil.cs b/PKHeX.WinForms/Controls/Slots/SlotUtil.cs index 0f4c24868..f61afcdb4 100644 --- a/PKHeX.WinForms/Controls/Slots/SlotUtil.cs +++ b/PKHeX.WinForms/Controls/Slots/SlotUtil.cs @@ -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), }; diff --git a/PKHeX.WinForms/MainWindow/Main.cs b/PKHeX.WinForms/MainWindow/Main.cs index 4d87511a1..95b8ac306 100644 --- a/PKHeX.WinForms/MainWindow/Main.cs +++ b/PKHeX.WinForms/MainWindow/Main.cs @@ -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) diff --git a/PKHeX.WinForms/Subforms/PKM Editors/BatchEditor.Designer.cs b/PKHeX.WinForms/Subforms/PKM Editors/BatchEditor.Designer.cs index bc4d47c1d..0f7b822b1 100644 --- a/PKHeX.WinForms/Subforms/PKM Editors/BatchEditor.Designer.cs +++ b/PKHeX.WinForms/Subforms/PKM Editors/BatchEditor.Designer.cs @@ -13,9 +13,12 @@ partial class BatchEditor /// true if managed resources should be disposed; otherwise, false. 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; } } diff --git a/PKHeX.WinForms/Subforms/PKM Editors/BatchEditor.cs b/PKHeX.WinForms/Subforms/PKM Editors/BatchEditor.cs index 83d28c8b0..50e33f26f 100644 --- a/PKHeX.WinForms/Subforms/PKM Editors/BatchEditor.cs +++ b/PKHeX.WinForms/Subforms/PKM Editors/BatchEditor.cs @@ -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? _boxData; + private IReadOnlyList? _party; + private IReadOnlyList? _folder; + private readonly Dictionary _folderPaths = new(); + private readonly HashSet _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) + /// + /// Remember the last used commands so that they can be restored when the form is reopened. + /// + 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 GetModifiedSlots() => [.. _modifiedSlots]; + + private IReadOnlyList CreateBoxData() + { + var data = new List(_sav.SlotCount); + SlotInfoLoader.AddBoxData(_sav, data); + return data; + } + + private IReadOnlyList CreatePartyData() + { + var data = new List(_sav.PartyCount); + SlotInfoLoader.AddPartyData(_sav, data); + return data; + } + + private IReadOnlyList CreateFolderData() + { + if (!Directory.Exists(TB_Folder.Text)) + return []; + + var result = new List(); + IEnumerable 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 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 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 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 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 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 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 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 sets, bool boxes = false, bool party = false) - { - if (party) - { - var data = new List(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(SAV.SlotCount); - SlotInfoLoader.AddBoxData(SAV, data); - process(data); - foreach (var slot in data) - slot.Source.WriteTo(SAV, slot.Entity, EntityImportSettings.None); - } - void process(IList 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 data, IReadOnlyList Filters, IReadOnlyList Instructions) + private void RunBatchEditSaveFile(IReadOnlyCollection 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 data, IReadOnlyList filters, IReadOnlyList 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 files, string destDir, IReadOnlyList pkFilters, IReadOnlyList instructions) + private void RunBatchEditFolder(IReadOnlyCollection 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 data, string destDir, IReadOnlyList pkFilters, IReadOnlyList 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 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 metaFilters, IReadOnlyList pkFilters, IReadOnlyList 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 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; } diff --git a/PKHeX.WinForms/Subforms/Save Editors/Gen9/SAV_DonutGenerator9a.Designer.cs b/PKHeX.WinForms/Subforms/Save Editors/Gen9/SAV_DonutGenerator9a.Designer.cs index 4dcf714c2..b780bfc90 100644 --- a/PKHeX.WinForms/Subforms/Save Editors/Gen9/SAV_DonutGenerator9a.Designer.cs +++ b/PKHeX.WinForms/Subforms/Save Editors/Gen9/SAV_DonutGenerator9a.Designer.cs @@ -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);