From 5029f782472c3418b53335bf71855bb5525bea2a Mon Sep 17 00:00:00 2001 From: achiez Date: Fri, 19 Jun 2026 21:18:34 +0300 Subject: [PATCH] feat(export): add zip export, export-all, and login:password input parsing --- .../Localization/dialogs.export.loc.json | 36 +++++++--- .../MafileExport/MafileExportTemplate.cs | 1 + .../Model/MafileExport/MafileExporter.cs | 71 +++++++++++++------ src/NebulaAuth/View/MafileExporterView.xaml | 10 ++- .../ViewModel/Other/MafileExporterVM.cs | 52 ++++++++------ 5 files changed, 118 insertions(+), 52 deletions(-) diff --git a/src/NebulaAuth/Localization/dialogs.export.loc.json b/src/NebulaAuth/Localization/dialogs.export.loc.json index fe35dc2..787b67c 100644 --- a/src/NebulaAuth/Localization/dialogs.export.loc.json +++ b/src/NebulaAuth/Localization/dialogs.export.loc.json @@ -196,6 +196,16 @@ "es": "Grupo", "tr": "Grup", "kk": "Топ" + }, + "ExportToZip": { + "ru": "Экспортировать в ZIP архив", + "en": "Export to ZIP archive", + "uk": "Експортувати в ZIP архів", + "fr": "Exporter en archive ZIP", + "zh": "导出为 ZIP 压缩包", + "es": "Exportar a archivo ZIP", + "tr": "ZIP arşivine aktar", + "kk": "ZIP мұрағатқа экспорттау" } }, "Tooltips": { @@ -288,17 +298,27 @@ "es": "Incluir datos de grupo compatibles con Nebula.", "tr": "Nebula uyumlu grup verilerini dahil et.", "kk": "Nebula үйлесімді топ деректерін қосу." + }, + "ExportToZip": { + "ru": "Экспортировать в ZIP архив вместо папки. Архив будет сохранён в выбранной директории с именем вида N_timestamp.zip.", + "en": "Export to a ZIP archive instead of a folder. The archive will be saved in the selected directory as N_timestamp.zip.", + "uk": "Експортувати в ZIP архів замість папки. Архів буде збережено у вибраній директорії з назвою N_timestamp.zip.", + "fr": "Exporter dans une archive ZIP plutôt que dans un dossier. L'archive sera enregistrée dans le répertoire sélectionné sous le nom N_timestamp.zip.", + "zh": "导出为 ZIP 压缩包而非文件夹。压缩包将保存在所选目录中,名称格式为 N_timestamp.zip。", + "es": "Exportar a un archivo ZIP en lugar de una carpeta. El archivo se guardará en el directorio seleccionado como N_timestamp.zip.", + "tr": "Klasör yerine ZIP arşivine aktar. Arşiv, seçilen dizine N_timestamp.zip adıyla kaydedilecektir.", + "kk": "Қалта орнына ZIP мұрағатына экспорттау. Мұрағат таңдалған каталогта N_timestamp.zip атауымен сақталады." } }, "ExportAccountsPlaceholder": { - "ru": "Введите логины аккаунтов или SteamID (по одному на строку) для экспорта в выбранную директорию.", - "en": "Enter account logins or SteamIDs (one per line) to export to the selected directory.", - "uk": "Введіть логіни акаунтів або SteamID (по одному на рядок) для експорту у вибрану директорію.", - "fr": "Entrez les identifiants de compte ou les SteamID (un par ligne) pour exporter vers le répertoire sélectionné.", - "zh": "输入要导出到所选目录的账户登录名或 SteamID(每行一个)。", - "es": "Introduce los logins de las cuentas o SteamID (uno por línea) para exportarlos al directorio seleccionado.", - "tr": "Seçilen dizine dışa aktarmak için hesap girişlerini veya SteamID’leri (her satıra bir tane) girin.", - "kk": "Таңдалған каталогқа экспорттау үшін аккаунт логиндерін немесе SteamID (әр жолға біреуден) енгізіңіз." + "ru": "Введите логины или SteamID (по одному на строку). Поддерживается формат логин:пароль — пароль будет проигнорирован. Оставьте пустым для экспорта всех аккаунтов.", + "en": "Enter logins or SteamIDs (one per line). Supports login:password format — the password will be ignored. Leave empty to export all accounts.", + "uk": "Введіть логіни або SteamID (по одному на рядок). Підтримується формат логін:пароль — пароль буде проігноровано. Залиште порожнім для експорту всіх акаунтів.", + "fr": "Entrez les identifiants ou SteamID (un par ligne). Supporte le format login:mot_de_passe — le mot de passe sera ignoré. Laissez vide pour exporter tous les comptes.", + "zh": "输入登录名或 SteamID(每行一个)。支持 login:password 格式——密码将被忽略。留空则导出所有账户。", + "es": "Introduce logins o SteamID (uno por línea). Soporta el formato login:contraseña — la contraseña será ignorada. Déjalo vacío para exportar todas las cuentas.", + "tr": "Girişleri veya SteamID’leri girin (her satıra bir tane). login:şifre formatı desteklenir — şifre yok sayılır. Tüm hesapları dışa aktarmak için boş bırakın.", + "kk": "Логиндерді немесе SteamID (әр жолға біреуден) енгізіңіз. логин:құпиясөз форматы қолданылады — құпиясөз еленбейді. Барлық аккаунттарды экспорттау үшін бос қалдырыңыз." } } } \ No newline at end of file diff --git a/src/NebulaAuth/Model/MafileExport/MafileExportTemplate.cs b/src/NebulaAuth/Model/MafileExport/MafileExportTemplate.cs index 3a5311f..01459c5 100644 --- a/src/NebulaAuth/Model/MafileExport/MafileExportTemplate.cs +++ b/src/NebulaAuth/Model/MafileExport/MafileExportTemplate.cs @@ -13,4 +13,5 @@ public class MafileExportTemplate public bool IncludeNebulaPassword { get; set; } public bool IncludeNebulaGroup { get; set; } public string? Path { get; set; } + public bool ExportToZip { get; set; } } \ No newline at end of file diff --git a/src/NebulaAuth/Model/MafileExport/MafileExporter.cs b/src/NebulaAuth/Model/MafileExport/MafileExporter.cs index 9774193..b808d6b 100644 --- a/src/NebulaAuth/Model/MafileExport/MafileExporter.cs +++ b/src/NebulaAuth/Model/MafileExport/MafileExporter.cs @@ -1,6 +1,9 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.IO; +using System.IO.Compression; using System.Linq; +using System.Text; using System.Threading.Tasks; using AchiesUtilities.Extensions; using NebulaAuth.Core; @@ -36,48 +39,79 @@ public static class MafileExporter return new ExportResult(LocManager.GetCodeBehindOrDefault("AccessDenied", "MafileExporter.AccessDenied")); } - var exported = new Dictionary(); var notFound = new List(); var conflict = new List(); + var toExport = new List<(string key, Mafile mafile)>(); foreach (var key in keys) { SteamId? steamId = null; if (SteamId64.TryParse(key, out var id64)) - { steamId = id64; - } var maf = Storage.MaFiles.FirstOrDefault(m => m.AccountName.EqualsIgnoreCase(key) || m.SteamId == steamId); if (maf != null) + toExport.Add((key, maf)); + else + notFound.Add(key); + } + + if (template.ExportToZip) + { + if (toExport.Count > 0) { - var fileName = await ExportMafile(template.Path, template, maf); - if (fileName == null) + var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"); + var zipName = $"{toExport.Count}_{timestamp}.zip"; + var zipPath = Path.Combine(template.Path, zipName); + + await using var zipStream = File.Create(zipPath); + using var archive = new ZipArchive(zipStream, ZipArchiveMode.Create); + var addedEntries = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var (key, maf) in toExport) + { + var (content, fileName) = BuildMafileContent(template, maf); + if (!addedEntries.Add(fileName)) + { + conflict.Add(key); + continue; + } + + var entry = archive.CreateEntry(fileName); + await using var entryStream = entry.Open(); + await using var writer = new StreamWriter(entryStream, Encoding.UTF8); + await writer.WriteAsync(content); + exported[maf] = fileName; + } + } + } + else + { + foreach (var (key, maf) in toExport) + { + var (content, fileName) = BuildMafileContent(template, maf); + var fullPath = Path.Combine(template.Path, fileName); + if (File.Exists(fullPath)) { conflict.Add(key); continue; } - exported[maf] = fileName; - } - else - { - notFound.Add(key); + await File.WriteAllTextAsync(fullPath, content); + exported[maf] = fullPath; } } return new ExportResult(exported, notFound, conflict); } - private static async Task ExportMafile(string path, MafileExportTemplate template, Mafile mafile) + private static (string content, string fileName) BuildMafileContent(MafileExportTemplate template, Mafile mafile) { - // We preserve SteamId even if other info is not included var session = template.IncludeSessionData ? mafile.SessionData : new MobileSessionData(null!, mafile.SteamId, default, null, tokens: null); - var serializeMaf = new Mafile { SharedSecret = template.IncludeSharedSecret ? mafile.SharedSecret : null!, @@ -102,14 +136,7 @@ public static class MafileExporter var serialized = NebulaSerializer.SerializeMafile(serializeMaf); var strategy = template.UseLoginAsMafileName ? MafileNamingStrategy.Login : MafileNamingStrategy.SteamId; var fileName = strategy.GetMafileName(serializeMaf); - var fullPath = Path.Combine(path, fileName); - if (File.Exists(fullPath)) - { - return null; - } - - await File.WriteAllTextAsync(fullPath, serialized); - return fullPath; + return (serialized, fileName); } private static bool PathIsValid(string path) diff --git a/src/NebulaAuth/View/MafileExporterView.xaml b/src/NebulaAuth/View/MafileExporterView.xaml index 3d4281d..38afeda 100644 --- a/src/NebulaAuth/View/MafileExporterView.xaml +++ b/src/NebulaAuth/View/MafileExporterView.xaml @@ -9,8 +9,8 @@ mc:Ignorable="d" TextElement.Foreground="{DynamicResource BaseContentBrush}" FontFamily="{materialDesign:MaterialDesignFont}" - MinWidth="400" - MaxWidth="400" + MinWidth="500" + MaxWidth="500" Background="{DynamicResource WindowBackground}" d:DataContext="{d:DesignInstance other:MafileExporterVM}" d:Background="#141119" @@ -113,6 +113,12 @@ IsEnabled="{Binding CurrentTemplate, Converter={StaticResource NullableToBooleanConverter}}"> + + diff --git a/src/NebulaAuth/ViewModel/Other/MafileExporterVM.cs b/src/NebulaAuth/ViewModel/Other/MafileExporterVM.cs index 97e2228..5d9a8d4 100644 --- a/src/NebulaAuth/ViewModel/Other/MafileExporterVM.cs +++ b/src/NebulaAuth/ViewModel/Other/MafileExporterVM.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; @@ -10,6 +10,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using NebulaAuth.Core; using NebulaAuth.Model.MafileExport; +using NebulaAuth.Model.Mafiles; namespace NebulaAuth.ViewModel.Other; @@ -24,8 +25,7 @@ public partial class MafileExporterVM : ObservableObject public ObservableCollection Templates { get; } - [ObservableProperty] [NotifyCanExecuteChangedFor(nameof(ExportCommand))] - private string? _accountsText; + [ObservableProperty] private string? _accountsText; private string? _cachedName; private MafileExportTemplateVM? _currentTemplate; @@ -160,6 +160,7 @@ public partial class MafileExporterVM : ObservableObject IncludeNebulaProxy = true, IncludeNebulaPassword = true, IncludeNebulaGroup = true, + ExportToZip = false, Path = null }; MafileExporterStorage.AddTemplate(template); @@ -211,21 +212,26 @@ public partial class MafileExporterVM : ObservableObject } } - [RelayCommand(CanExecute = nameof(ExportCanExecute))] + [RelayCommand(CanExecute = nameof(CurrentTemplateNotNull))] private async Task Export() { - var lines = AccountsText; var template = CurrentTemplate; - if (string.IsNullOrWhiteSpace(lines) || template == null) - { - return; - } + if (template == null) return; - var split = Regex - .Split(lines, "\r\n|\r|\n") - .Select(x => x.Trim()) - .Where(x => !string.IsNullOrWhiteSpace(x)) - .ToArray(); + string[] split; + if (string.IsNullOrWhiteSpace(AccountsText)) + { + split = Storage.MaFiles.Select(m => m.AccountName).ToArray(); + } + else + { + split = Regex + .Split(AccountsText, "\r\n|\r|\n") + .Select(x => x.Trim()) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Select(ExtractLogin) + .ToArray(); + } ResetHintText(); ExportResult res; @@ -268,6 +274,14 @@ public partial class MafileExporterVM : ObservableObject AccountsText = text; } + // Strips the password part from "login:password" input lines. + // SteamId64 values contain no colon, so any colon signals the login:password format. + private static string ExtractLogin(string line) + { + var idx = line.IndexOf(':'); + return idx > 0 ? line[..idx] : line; + } + private void SetHintText(string? text, HintBoxSeverity severity = HintBoxSeverity.Error) { HintText = text; @@ -281,12 +295,6 @@ public partial class MafileExporterVM : ObservableObject #region CanExecute - private bool ExportCanExecute() - { - return CurrentTemplateNotNull() && !string.IsNullOrWhiteSpace(AccountsText); - } - - private bool CurrentTemplateNotNull() { return CurrentTemplate != null; @@ -307,6 +315,7 @@ public partial class MafileExporterVM : ObservableObject public partial class MafileExportTemplateVM : ObservableObject { + [ObservableProperty] private bool _exportToZip; [ObservableProperty] private bool _includeIdentitySecret; [ObservableProperty] private bool _includeNebulaGroup; [ObservableProperty] private bool _includeNebulaPassword; @@ -334,6 +343,7 @@ public partial class MafileExportTemplateVM : ObservableObject IncludeNebulaProxy = x.IncludeNebulaProxy, IncludeNebulaPassword = x.IncludeNebulaPassword, IncludeNebulaGroup = x.IncludeNebulaGroup, + ExportToZip = x.ExportToZip, Path = x.Path }; } @@ -352,6 +362,7 @@ public partial class MafileExportTemplateVM : ObservableObject IncludeNebulaProxy = IncludeNebulaProxy, IncludeNebulaPassword = IncludeNebulaPassword, IncludeNebulaGroup = IncludeNebulaGroup, + ExportToZip = ExportToZip, Path = Path }; } @@ -368,6 +379,7 @@ public partial class MafileExportTemplateVM : ObservableObject model.IncludeNebulaProxy = IncludeNebulaProxy; model.IncludeNebulaPassword = IncludeNebulaPassword; model.IncludeNebulaGroup = IncludeNebulaGroup; + model.ExportToZip = ExportToZip; model.Path = Path; } } \ No newline at end of file