From c0b78e6b6a78870fa45d882567aa58dcf5e8f2dc Mon Sep 17 00:00:00 2001 From: Kurt Date: Sun, 16 Aug 2026 02:30:29 -0500 Subject: [PATCH] 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 --- .../Editing/Saves/Slots/SlotChangelog.cs | 216 ++++-- PKHeX.Core/Editing/Saves/Slots/SlotEditor.cs | 119 +++- .../Editing/Saves/Slots/SlotTouchType.cs | 4 + PKHeX.WinForms/Controls/Slots/SlotUtil.cs | 2 + PKHeX.WinForms/MainWindow/Main.cs | 11 +- .../PKM Editors/BatchEditor.Designer.cs | 183 ++++-- .../Subforms/PKM Editors/BatchEditor.cs | 621 ++++++++++++------ .../Gen9/SAV_DonutGenerator9a.Designer.cs | 1 - 8 files changed, 852 insertions(+), 305 deletions(-) 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);