feat(export): add zip export, export-all, and login:password input parsing

This commit is contained in:
achiez
2026-06-19 21:18:34 +03:00
parent b9452c4baf
commit 5029f78247
5 changed files with 118 additions and 52 deletions
@@ -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 SteamIDleri (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 SteamIDleri 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 (әр жолға біреуден) енгізіңіз. логин:құпиясөз форматы қолданылады — құпиясөз еленбейді. Барлық аккаунттарды экспорттау үшін бос қалдырыңыз."
}
}
}
@@ -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; }
}
@@ -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<Mafile, string>();
var notFound = new List<string>();
var conflict = new List<string>();
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<string>(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<string?> 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)
+8 -2
View File
@@ -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}}">
<StackPanel>
<CheckBox IsChecked="{Binding CurrentTemplate.ExportToZip, FallbackValue=False}"
Margin="3,0,0,3"
Content="{Tr ExportDialog.ExportOptions.ExportToZip}"
ToolTipService.InitialShowDelay="200"
ToolTip="{Tr ExportDialog.Tooltips.ExportToZip}" />
<TextBlock FontWeight="DemiBold"
Text="{Tr ExportDialog.ExportSettingsCommon}" />
@@ -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<MafileExportTemplateVM> 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;
}
}