Skip to content

Commit 0dd0ce6

Browse files
authored
Merge pull request #725 from codemonkey85/dev
feat(bulk): add .pk* batch import/export dialogs
2 parents 3f5c491 + 804e22f commit 0dd0ce6

File tree

8 files changed

+917
-0
lines changed

8 files changed

+917
-0
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<MudDialog>
2+
<TitleContent>
3+
<MudText Typo="@Typo.h6">Export Pokémon as .pk* Files</MudText>
4+
</TitleContent>
5+
<DialogContent>
6+
<MudStack Spacing="3">
7+
<MudText Typo="@Typo.body2"
8+
Color="@Color.Secondary">
9+
Downloads a single zip containing a .pk* file for each non-empty slot.
10+
</MudText>
11+
12+
<MudRadioGroup T="BulkExportDialog.BulkExportScope"
13+
@bind-Value="scope">
14+
<MudRadio T="BulkExportDialog.BulkExportScope"
15+
Value="@BulkExportDialog.BulkExportScope.Party"
16+
Color="@Color.Primary"
17+
Disabled="@(partyCount == 0)">
18+
Party (@partyCount Pokémon)
19+
</MudRadio>
20+
<MudRadio T="BulkExportDialog.BulkExportScope"
21+
Value="@BulkExportDialog.BulkExportScope.CurrentBox"
22+
Color="@Color.Primary"
23+
Disabled="@(currentBoxCount == 0)">
24+
Current box (@currentBoxCount Pokémon)
25+
</MudRadio>
26+
<MudRadio T="BulkExportDialog.BulkExportScope"
27+
Value="@BulkExportDialog.BulkExportScope.AllBoxes"
28+
Color="@Color.Primary"
29+
Disabled="@(totalBoxCount == 0)">
30+
All boxes (@totalBoxCount Pokémon)
31+
</MudRadio>
32+
<MudRadio T="BulkExportDialog.BulkExportScope"
33+
Value="@BulkExportDialog.BulkExportScope.Everything"
34+
Color="@Color.Primary"
35+
Disabled="@(partyCount + totalBoxCount == 0)">
36+
Party and all boxes (@(partyCount + totalBoxCount) Pokémon)
37+
</MudRadio>
38+
</MudRadioGroup>
39+
40+
@if (isExporting)
41+
{
42+
<MudProgressLinear Color="@Color.Primary"
43+
Indeterminate="true"/>
44+
}
45+
</MudStack>
46+
</DialogContent>
47+
<DialogActions>
48+
<MudButton OnClick="@Cancel"
49+
StartIcon="@Icons.Material.Filled.Clear"
50+
Variant="@Variant.Filled"
51+
Disabled="@isExporting">
52+
Cancel
53+
</MudButton>
54+
<MudButton OnClick="@ExportAsync"
55+
Color="@Color.Primary"
56+
StartIcon="@Icons.Material.Filled.Download"
57+
Variant="@Variant.Filled"
58+
Disabled="@(isExporting || GetScopedCount() == 0 || AppState.SaveFile is null)">
59+
Export
60+
</MudButton>
61+
</DialogActions>
62+
</MudDialog>
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
using System.IO.Compression;
2+
3+
namespace Pkmds.Rcl.Components.Dialogs;
4+
5+
public partial class BulkExportDialog
6+
{
7+
public enum BulkExportScope
8+
{
9+
Party,
10+
CurrentBox,
11+
AllBoxes,
12+
Everything
13+
}
14+
15+
private int currentBoxCount;
16+
private bool isExporting;
17+
private int partyCount;
18+
private BulkExportScope scope = BulkExportScope.CurrentBox;
19+
private int totalBoxCount;
20+
21+
[CascadingParameter]
22+
private IMudDialogInstance? MudDialog { get; set; }
23+
24+
protected override void OnInitialized()
25+
{
26+
if (AppState.SaveFile is not { } sav)
27+
{
28+
return;
29+
}
30+
31+
var currentBox = AppState.BoxEdit?.CurrentBox ?? sav.CurrentBox;
32+
currentBoxCount = CountBox(sav, currentBox);
33+
34+
var total = 0;
35+
for (var box = 0; box < sav.BoxCount; box++)
36+
{
37+
total += CountBox(sav, box);
38+
}
39+
40+
totalBoxCount = total;
41+
partyCount = CountParty(sav);
42+
43+
// Default scope: prefer current box, then any box, then party.
44+
if (currentBoxCount > 0)
45+
{
46+
scope = BulkExportScope.CurrentBox;
47+
}
48+
else if (totalBoxCount > 0)
49+
{
50+
scope = BulkExportScope.AllBoxes;
51+
}
52+
else if (partyCount > 0)
53+
{
54+
scope = BulkExportScope.Party;
55+
}
56+
}
57+
58+
private static int CountBox(SaveFile sav, int box)
59+
{
60+
var count = 0;
61+
for (var slot = 0; slot < sav.BoxSlotCount; slot++)
62+
{
63+
if (sav.GetBoxSlotAtIndex(box, slot).Species != 0)
64+
{
65+
count++;
66+
}
67+
}
68+
69+
return count;
70+
}
71+
72+
private static int CountParty(SaveFile sav)
73+
{
74+
var count = 0;
75+
for (var i = 0; i < sav.PartyCount; i++)
76+
{
77+
if (sav.GetPartySlotAtIndex(i).Species != 0)
78+
{
79+
count++;
80+
}
81+
}
82+
83+
return count;
84+
}
85+
86+
private int GetScopedCount() => scope switch
87+
{
88+
BulkExportScope.Party => partyCount,
89+
BulkExportScope.CurrentBox => currentBoxCount,
90+
BulkExportScope.AllBoxes => totalBoxCount,
91+
BulkExportScope.Everything => partyCount + totalBoxCount,
92+
_ => 0
93+
};
94+
95+
private async Task ExportAsync()
96+
{
97+
if (AppState.SaveFile is not { } sav)
98+
{
99+
return;
100+
}
101+
102+
var pokemonToExport = CollectPokemon(sav);
103+
if (pokemonToExport.Count == 0)
104+
{
105+
Snackbar.Add("No Pokémon to export.", Severity.Warning);
106+
return;
107+
}
108+
109+
isExporting = true;
110+
StateHasChanged();
111+
112+
try
113+
{
114+
var zipBytes = BuildZip(pokemonToExport);
115+
var fileName = scope switch
116+
{
117+
BulkExportScope.Party => "pokemon_export_party.zip",
118+
BulkExportScope.CurrentBox =>
119+
$"pokemon_export_box{(AppState.BoxEdit?.CurrentBox ?? sav.CurrentBox) + 1}.zip",
120+
BulkExportScope.AllBoxes => "pokemon_export_boxes.zip",
121+
BulkExportScope.Everything => "pokemon_export.zip",
122+
_ => "pokemon_export.zip"
123+
};
124+
125+
await WriteZipAsync(zipBytes, fileName);
126+
127+
Snackbar.Add($"Exported {pokemonToExport.Count} Pokémon.", Severity.Success);
128+
MudDialog?.Close();
129+
}
130+
catch (Exception ex)
131+
{
132+
Snackbar.Add($"Export failed: {ex.Message}", Severity.Error);
133+
}
134+
finally
135+
{
136+
isExporting = false;
137+
StateHasChanged();
138+
}
139+
}
140+
141+
private List<PKM> CollectPokemon(SaveFile sav)
142+
{
143+
var result = new List<PKM>();
144+
145+
if (scope is BulkExportScope.Party or BulkExportScope.Everything)
146+
{
147+
for (var i = 0; i < sav.PartyCount; i++)
148+
{
149+
var pkm = sav.GetPartySlotAtIndex(i);
150+
if (pkm.Species != 0)
151+
{
152+
result.Add(pkm);
153+
}
154+
}
155+
}
156+
157+
var boxes = scope switch
158+
{
159+
BulkExportScope.CurrentBox => new[] { AppState.BoxEdit?.CurrentBox ?? sav.CurrentBox },
160+
BulkExportScope.AllBoxes or BulkExportScope.Everything => Enumerable.Range(0, sav.BoxCount).ToArray(),
161+
_ => []
162+
};
163+
164+
foreach (var box in boxes)
165+
{
166+
for (var slot = 0; slot < sav.BoxSlotCount; slot++)
167+
{
168+
var pkm = sav.GetBoxSlotAtIndex(box, slot);
169+
if (pkm.Species != 0)
170+
{
171+
result.Add(pkm);
172+
}
173+
}
174+
}
175+
176+
return result;
177+
}
178+
179+
private byte[] BuildZip(IReadOnlyList<PKM> pokemon)
180+
{
181+
using var ms = new MemoryStream();
182+
using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
183+
{
184+
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
185+
foreach (var pkm in pokemon)
186+
{
187+
pkm.RefreshChecksum();
188+
var bytes = new byte[pkm.SIZE_PARTY];
189+
pkm.WriteDecryptedDataParty(bytes);
190+
191+
var entryName = GetUniqueEntryName(usedNames, AppService.GetCleanFileName(pkm));
192+
var entry = zip.CreateEntry(entryName, CompressionLevel.Fastest);
193+
using var es = entry.Open();
194+
es.Write(bytes);
195+
}
196+
}
197+
198+
return ms.ToArray();
199+
}
200+
201+
private static string GetUniqueEntryName(HashSet<string> used, string desired)
202+
{
203+
if (used.Add(desired))
204+
{
205+
return desired;
206+
}
207+
208+
var ext = Path.GetExtension(desired);
209+
var stem = Path.GetFileNameWithoutExtension(desired);
210+
for (var i = 2; ; i++)
211+
{
212+
var candidate = $"{stem}_{i}{ext}";
213+
if (used.Add(candidate))
214+
{
215+
return candidate;
216+
}
217+
}
218+
}
219+
220+
private async Task WriteZipAsync(byte[] data, string fileName)
221+
{
222+
// Mirror MainLayout.WriteFile: prefer File System Access API, fall back to anchor.
223+
if (await FileSystemAccessService.IsSupportedAsync())
224+
{
225+
try
226+
{
227+
await JSRuntime.InvokeVoidAsync(
228+
"showFilePickerAndWrite",
229+
fileName,
230+
data,
231+
".zip",
232+
"Pokémon Export");
233+
return;
234+
}
235+
catch (JSException ex) when (ex.Message.Contains("AbortError", StringComparison.OrdinalIgnoreCase) ||
236+
ex.Message.Contains("aborted a request", StringComparison.OrdinalIgnoreCase))
237+
{
238+
// User dismissed the picker — not an error.
239+
return;
240+
}
241+
}
242+
243+
// Legacy fallback: base64 data-URI anchor click.
244+
var base64 = Convert.ToBase64String(data);
245+
var anchor = await JSRuntime.InvokeAsync<IJSObjectReference>("eval", "document.createElement('a')");
246+
await anchor.InvokeVoidAsync("setAttribute", "href", $"data:application/zip;base64,{base64}");
247+
await anchor.InvokeVoidAsync("setAttribute", "download", fileName);
248+
await anchor.InvokeVoidAsync("click");
249+
}
250+
251+
private void Cancel() => MudDialog?.Close(DialogResult.Cancel());
252+
}

0 commit comments

Comments
 (0)