Files
decePubClient/Components/Settings/PersonaArchivePanel.razor
T
thepraandClaude Opus 5.5 48df316256 Settings takes a persona's whole archive away and brings one back
Data import/export gains the persona's archive (PrivaPub's /clientapi/persona/{id}/archive): made on PrivaPub in
Mastodon's account-archive layout and downloaded through a link; and an archive, its own or Mastodon's, uploaded in
pieces straight from the picked file (js/transfer.js) and imported into the persona, the parts chosen (likes, located
and scheduled posts only when ticked), with progress, a report of what came and what was left out, and Stop. Imported
posts are delivered to no one.

e2e: a town persona's archive downloaded and imported into another, whose profile then shows the posts; the copies are
taken away after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-07 12:49:51 +02:00

365 lines
13 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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. *@
<div class="flex flex-col gap-5 w-full" data-archive>
<section class="flex flex-col gap-2" data-archive-export>
<h3 class="font-semibold text-sm">@Localizer["This persona's archive"]</h3>
<p class="text-sm opacity-70">@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."]</p>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || busy || state.ExportState is "queued" or "running")" OnClick="Export" data-archive-make>
@if (state.ExportState is "queued" or "running")
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
else
{
<i class="ion-md-archive"></i>
}
<span>@Localizer["Make the archive"]</span>
</DButton>
@if (state.ExportState == "ready")
{
@if (link is null)
{
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy)" OnClick="Link" data-archive-get-link>
<i class="ion-md-download"></i> @Localizer["Get the link"]
</DButton>
}
else
{
<a class="btn btn-primary btn-sm" href="@link" data-archive-link>
<i class="ion-md-download"></i> @Localizer["Download {0}", state.ExportBytes.GetFileSize(Localizer)]
</a>
}
<span class="text-xs opacity-70" title="@state.ExportReadyAt?.ToLocalTime().ToString(SUtility.dateTimeFormatExt)">
@Localizer["Made {0}; kept until {1}.", state.ExportReadyAt?.GetPassedTime(Localizer) ?? "", state.ExportExpiresAt?.ToLocalTime().ToString(SUtility.dateTimeFormatExt) ?? ""]
</span>
}
</div>
@if (state.ExportState == "failed")
{
<ErrorViewer AdditionalError="@Localizer["The archive could not be made: {0}", state.ExportError ?? ""]" />
}
</section>
<section class="flex flex-col gap-2" data-archive-import>
<h3 class="font-semibold text-sm">@Localizer["Restore an archive into this persona"]</h3>
<p class="text-sm opacity-70">@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."]</p>
<DFileInput Size="DSize.Sm" Accept=".zip,application/zip" OnChange="Picked" disabled="@(IsDefaultDisabled || Importing)" data-archive-file />
<div class="flex flex-wrap gap-x-4 gap-y-2 pointer-coarse:gap-2">
@foreach (var part in Parts)
{
<DCheckbox Value="@chosen.Contains(part)" ValueChanged="on => Choose(part, on)" ValueExpression="() => chosenFlag" Size="DSize.Sm"
LabelText="@PartName(part)" disabled="@(IsDefaultDisabled || Importing)" data-archive-part="@part" />
}
</div>
<p class="text-xs opacity-70">@Localizer["Liking again tells each post's author; located and scheduled posts come from PrivaPub's archives only."]</p>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
@if (!Importing)
{
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || busy || pickedName is null || chosen.Count == 0)" OnClick="Import"
data-archive-import-start>
<i class="ion-md-cloud-upload"></i> @Localizer["Import"]
</DButton>
}
else
{
<DButton Size="DSize.Sm" Color="DColor.Warning" Disabled="@(state.ImportState is not ("queued" or "running"))" OnClick="Stop" data-archive-stop>
@Localizer["Stop"]
</DButton>
}
</div>
@if (uploading)
{
<DProgress class="w-full max-w-md" Value="@uploadedPercent" Max="100" Color="DColor.Primary" />
<span class="text-xs opacity-70">@Localizer["Sending {0} of {1}", uploaded.GetFileSize(Localizer), uploadSize.GetFileSize(Localizer)]</span>
}
@if (state.ImportState is not (null or "none"))
{
<div class="flex flex-col gap-1" data-archive-report data-archive-state="@state.ImportState">
@if (state.ImportState is "queued" or "running")
{
<DProgress class="w-full max-w-md" Value="@state.ImportDone" Max="@Math.Max(1, state.ImportTotal)" />
}
<span class="text-sm">
@(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 ?? ""]
})
</span>
@if (state.ImportFrom is { Length: > 0 } from)
{
<span class="text-xs opacity-70">@(state.ImportSameActor ? Localizer["From this persona's own archive."] : Localizer["From {0}.", from])</span>
}
<ul class="text-xs tabular-nums">
@foreach (var (what, count) in state.ImportCounts.OrderBy(c => c.Key, StringComparer.Ordinal))
{
<li data-archive-count="@what">@count × @CountName(what)</li>
}
</ul>
</div>
}
</section>
</div>
@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<string> 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<PersonaArchivePanel> 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<IJSObjectReference>("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<UploadOutcome>("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)
{
}
}
}