@inherits LocalizableComponentBase @implements IAsyncDisposable @* The persona's whole archive (/clientapi/persona/{id}/archive; owner decision 2026-10-07): made on PrivaPub in Mastodon's layout and downloaded through a link, or another archive (its own, or Mastodon's) uploaded in pieces straight from the picked file (js/transfer.js) and imported into it, the parts chosen. Imported posts are delivered to no one. *@

@Localizer["This persona's archive"]

@Localizer["Everything the persona posted, with its pictures, what it follows, blocks and mutes, its lists, bookmarks and filters, in the archive Mastodon makes. Nothing of your login or your other personas."]

@if (state.ExportState is "queued" or "running") { } else { } @Localizer["Make the archive"] @if (state.ExportState == "ready") { @if (link is null) { @Localizer["Get the link"] } else { @Localizer["Download {0}", state.ExportBytes.GetFileSize(Localizer)] } @Localizer["Made {0}; kept until {1}.", state.ExportReadyAt?.GetPassedTime(Localizer) ?? "", state.ExportExpiresAt?.ToLocalTime().ToString(SUtility.dateTimeFormatExt) ?? ""] }
@if (state.ExportState == "failed") { }

@Localizer["Restore an archive into this persona"]

@Localizer["An archive made here, or by Mastodon. Its posts arrive on this persona's profile without being sent to anyone; follows are asked again."]

@foreach (var part in Parts) { }

@Localizer["Liking again tells each post's author; located and scheduled posts come from PrivaPub's archives only."]

@if (!Importing) { @Localizer["Import"] } else { @Localizer["Stop"] }
@if (uploading) { @Localizer["Sending {0} of {1}", uploaded.GetFileSize(Localizer), uploadSize.GetFileSize(Localizer)] } @if (state.ImportState is not (null or "none")) {
@if (state.ImportState is "queued" or "running") { } @(state.ImportState switch { "queued" => Localizer["Waiting to start."], "running" => Localizer["Importing: {0} of {1} read.", state.ImportDone, state.ImportTotal], "done" => Localizer["Imported."], "stopped" => Localizer["Stopped."], _ => Localizer["The import failed: {0}", state.ImportError ?? ""] }) @if (state.ImportFrom is { Length: > 0 } from) { @(state.ImportSameActor ? Localizer["From this persona's own archive."] : Localizer["From {0}.", from]) }
    @foreach (var (what, count) in state.ImportCounts.OrderBy(c => c.Key, StringComparer.Ordinal)) {
  • @count × @CountName(what)
  • }
}
@code { [Inject] ArchiveService Archives { get; set; } [Inject] TokenAuthStateProvider AuthStateProvider { get; set; } [Inject] AppConfiguration Configuration { get; set; } [Inject] IJSRuntime JS { get; set; } const string FileInput = "input[data-archive-file]"; static readonly TimeSpan PollEvery = TimeSpan.FromSeconds(3); static readonly string[] Parts = ["posts", "profile", "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags", "notificationpolicy", "pins", "located", "scheduled", "likes"]; ViewPersonaArchive state = new(); string loadedFor; HashSet chosen = ["posts", "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags", "pins"]; bool chosenFlag; bool busy; string link; string pickedName; bool uploading; long uploaded; long uploadSize; double uploadedPercent; IJSObjectReference transfer; DotNetObjectReference self; CancellationTokenSource polling; bool Importing => uploading || state.ImportState is "queued" or "running"; protected override async Task OnInitializedAsync() { if (CascadingState.IsOffline) { WhenOnline(LoadData); StartOnlinePolling(); IsLoading = false; return; } await LoadData(); IsLoading = false; } // another persona: its own archive protected override async Task OnParametersSetAsync() { if (!IsLoading && loadedFor != CascadingState.PersonaId) await LoadData(); } async Task LoadData() { loadedFor = CascadingState.PersonaId; link = null; if (loadedFor is null) return; state = await Archives.State(loadedFor) ?? new ViewPersonaArchive(); if (state.ExportState is "queued" or "running" || state.ImportState is "queued" or "running") Poll(); } void Poll() { if (polling is not null) return; polling = new(); var token = polling.Token; var persona = loadedFor; _ = Task.Run(async () => { using var timer = new PeriodicTimer(PollEvery); while (await timer.WaitForNextTickAsync(token)) { if (persona != loadedFor) break; if (await Archives.State(persona, token) is { } read) state = read; await InvokeAsync(StateHasChanged); if (state.ExportState is not ("queued" or "running") && state.ImportState is not ("queued" or "running")) break; } polling = null; }, token); } string PartName(string part) => part switch { "posts" => Localizer["Posts and their pictures"], "profile" => Localizer["Profile: name, words, fields, pictures"], "follows" => Localizer["Follows, asked again"], "blocks" => Localizer["Blocks"], "mutes" => Localizer["Mutes"], "domainblocks" => Localizer["Blocked servers"], "lists" => Localizer["Lists"], "bookmarks" => Localizer["Bookmarks"], "filters" => Localizer["Filters"], "tags" => Localizer["Followed hashtags"], "notificationpolicy" => Localizer["Notification policy"], "pins" => Localizer["Pinned posts"], "located" => Localizer["Located posts"], "scheduled" => Localizer["Scheduled posts"], "likes" => Localizer["Likes, given again"], _ => part }; string CountName(string what) => what switch { "posts" => Localizer["posts imported"], "posts already here" => Localizer["posts already here"], "posts deleted here" => Localizer["posts deleted here, left deleted"], "boosts skipped" => Localizer["boosts left out"], "direct or circle posts skipped" => Localizer["private posts left out"], "not the archive's own" => Localizer["posts by others left out"], "not a post" => Localizer["other items left out"], "media" => Localizer["pictures and files imported"], "media not in the archive" => Localizer["pictures not in the archive"], "media refused" => Localizer["pictures refused"], "located posts" => Localizer["located posts imported"], "located posts already here" => Localizer["located posts already here"], "scheduled posts" => Localizer["scheduled posts imported"], "scheduled posts past or refused" => Localizer["scheduled posts past or refused"], "followed hashtags" => Localizer["hashtags followed"], "notification policy" => Localizer["notification policy set"], "filters" => Localizer["filters imported"], "pins" => Localizer["posts pinned"], "blocked servers" => Localizer["servers blocked"], "blocks" => Localizer["accounts blocked"], "mutes" => Localizer["accounts muted"], "follows" => Localizer["follows asked"], "lists" => Localizer["lists made"], "list members" => Localizer["list members added"], "list members not followed" => Localizer["list members not followed"], "bookmarks" => Localizer["bookmarks imported"], "likes" => Localizer["likes given"], "profile" => Localizer["profile updated"], "pictures refused" => Localizer["pictures refused"], _ when what.EndsWith(" not found", StringComparison.Ordinal) => Localizer["not found: {0}", what[..^" not found".Length]], _ when what.EndsWith(" skipped", StringComparison.Ordinal) => Localizer["already there: {0}", what[..^" skipped".Length]], _ => what }; void Choose(string part, bool on) { if (on) chosen.Add(part); else chosen.Remove(part); } void Picked(InputFileChangeEventArgs e) => pickedName = e.FileCount > 0 ? e.File.Name : null; Uri ApiBase => new(string.IsNullOrEmpty(Configuration.ApiBaseAddress) ? Navigation.BaseUri : Configuration.ApiBaseAddress); async Task Export() { busy = true; var result = await Archives.Export(loadedFor); busy = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } state = (ViewPersonaArchive)result.Data; link = null; Poll(); } async Task Link() { busy = true; var result = await Archives.Ticket(loadedFor); busy = false; if (result.IsInvalid) { Toast.AddResponseError(result); return; } link = new Uri(ApiBase, ((ViewArchiveTicket)result.Data).Url).AbsoluteUri; } async Task Import() { transfer ??= await JS.InvokeAsync("import", "./js/transfer.js"); self ??= DotNetObjectReference.Create(this); var started = await Archives.StartUpload(); if (started.IsInvalid) { Toast.AddResponseError(started); return; } var upload = (ViewArchiveUpload)started.Data; (uploading, uploaded, uploadSize, uploadedPercent) = (true, 0, 0, 0); var endpoint = new Uri(ApiBase, APIs.ClientApi.PUT_M_PersonaArchiveUpload(upload.Id)).AbsoluteUri; var sent = await transfer.InvokeAsync("upload", FileInput, endpoint, await AuthStateProvider.GetToken(), upload.ChunkBytes, self); uploading = false; if (!sent.Ok) { await Archives.CancelUpload(upload.Id); Toast.AddResponseError(Localizer["The upload failed: {0}", sent.Error ?? sent.Status.ToString()]); return; } var asked = await Archives.Import(loadedFor, new ImportPersonaArchiveForm { UploadId = upload.Id, Parts = [.. Parts.Where(chosen.Contains)] }); if (asked.IsInvalid) { Toast.AddResponseError(asked); return; } state = (ViewPersonaArchive)asked.Data; Poll(); } async Task Stop() { var result = await Archives.Stop(loadedFor); if (result.IsInvalid) Toast.AddResponseError(result); } [JSInvokable] public Task Progress(long received, long total) { (uploaded, uploadSize) = (received, total); uploadedPercent = total == 0 ? 100 : 100.0 * received / total; return InvokeAsync(StateHasChanged); } sealed class UploadOutcome { public bool Ok { get; set; } public int Status { get; set; } public string Error { get; set; } } public async ValueTask DisposeAsync() { polling?.Cancel(); self?.Dispose(); if (transfer is null) return; try { await transfer.DisposeAsync(); } catch (JSDisconnectedException) { } } }