diff --git a/CLAUDE.md b/CLAUDE.md
index 000bfac..b3d344e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -380,7 +380,11 @@ supplies:
discoverable, indexable and posting defaults; the composer starts a new post at its default visibility), its mutes,
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows, lists, blocks, mutes, blocked servers
and bookmarks exported and imported as the CSV files Mastodon writes, so a persona moves between servers. An import
- resolves each row through PrivaPub and acts one row at a time; it can be stopped.
+ resolves each row through PrivaPub and acts one row at a time; it can be stopped. Above it, `PersonaArchivePanel`
+ (`ArchiveService`, `/clientapi/persona/{id}/archive`; owner decision 2026-10-07): the persona's whole archive made on
+ PrivaPub in Mastodon's layout and downloaded through a link; and an archive (its own, or Mastodon's) uploaded in pieces
+ by `js/transfer.js` and imported into it, the parts chosen (likes, located and scheduled posts off unless ticked),
+ with progress, a report and Stop. Imported posts are delivered to no one.
- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for
administrators the servers blocked for everyone, the federation statistics and the backups. PrivaPub has no admin list
of users, and adding an admin endpoint waits for the owner, so the Users section stays empty.
diff --git a/ClientJsonContext.cs b/ClientJsonContext.cs
index af3e3a4..bf85d02 100644
--- a/ClientJsonContext.cs
+++ b/ClientJsonContext.cs
@@ -64,6 +64,10 @@ namespace decePubClient;
[JsonSerializable(typeof(RestoreBackupForm))]
[JsonSerializable(typeof(ViewBackupTicket))]
[JsonSerializable(typeof(ViewBackupUpload))]
+[JsonSerializable(typeof(ViewPersonaArchive))]
+[JsonSerializable(typeof(ImportPersonaArchiveForm))]
+[JsonSerializable(typeof(ViewArchiveTicket))]
+[JsonSerializable(typeof(ViewArchiveUpload))]
public partial class ClientJsonContext : JsonSerializerContext
{
}
diff --git a/Components/Settings/PersonaArchivePanel.razor b/Components/Settings/PersonaArchivePanel.razor
new file mode 100644
index 0000000..6f3be30
--- /dev/null
+++ b/Components/Settings/PersonaArchivePanel.razor
@@ -0,0 +1,364 @@
+@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)
+ {
+ }
+ }
+}
diff --git a/Models/APIs.cs b/Models/APIs.cs
index f1830ee..46f36eb 100644
--- a/Models/APIs.cs
+++ b/Models/APIs.cs
@@ -51,6 +51,14 @@ public static class APIs
public static string PUT_M_AdminBackupUpload(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}";
public static string POST_M_AdminBackupUploadFinish(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}/finish";
public static string DELETE_M_AdminBackupUpload(string id) => $"clientapi/admin/backups/uploads/{Uri.EscapeDataString(id)}";
+ public static string GET_M_PersonaArchive(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive";
+ public static string POST_M_PersonaArchive(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive";
+ public static string POST_M_PersonaArchiveTicket(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive/ticket";
+ public static string POST_M_PersonaArchiveImport(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive/import";
+ public static string POST_M_PersonaArchiveImportStop(string personaId) => $"clientapi/persona/{Uri.EscapeDataString(personaId)}/archive/import/stop";
+ public const string POST_PersonaArchiveUpload = "clientapi/persona/archive/uploads";
+ public static string PUT_M_PersonaArchiveUpload(string id) => $"clientapi/persona/archive/uploads/{Uri.EscapeDataString(id)}";
+ public static string DELETE_M_PersonaArchiveUpload(string id) => $"clientapi/persona/archive/uploads/{Uri.EscapeDataString(id)}";
}
public static class OAuth
diff --git a/Pages/Settings.razor b/Pages/Settings.razor
index 69cbc21..d461402 100644
--- a/Pages/Settings.razor
+++ b/Pages/Settings.razor
@@ -133,7 +133,10 @@
@if (CascadingState.IsSignedIn)
{
-
+
}
else
{
diff --git a/Program.cs b/Program.cs
index 7f8919e..a24ada7 100644
--- a/Program.cs
+++ b/Program.cs
@@ -45,6 +45,7 @@ builder.Services.AddOptions()
.AddScoped
()
.AddScoped()
.AddScoped()
+ .AddScoped()
.AddScoped()
.AddScoped()
.AddScoped()
diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx
index 9c70433..b5af749 100644
--- a/Resources/AllStrings.it.resx
+++ b/Resources/AllStrings.it.resx
@@ -2081,4 +2081,169 @@
illeggibile
+
+ Un archivio fatto qui, o da Mastodon. I suoi post arrivano sul profilo di questa persona senza essere inviati a nessuno; i follow vengono richiesti di nuovo.
+
+
+ Blocchi
+
+
+ Tutto ciò che la persona ha pubblicato, con le sue immagini, chi segue, blocca e silenzia, le sue liste, i segnalibri e i filtri, nell'archivio che fa Mastodon. Niente del tuo account né delle tue altre persona.
+
+
+ Follow, richiesti di nuovo
+
+
+ Dall'archivio di questa stessa persona.
+
+
+ Da {0}.
+
+
+ Importato.
+
+
+ Importazione: {0} di {1} letti.
+
+
+ Mi piace, dati di nuovo
+
+
+ Rimettere i mi piace lo fa sapere all'autore di ogni post; i post localizzati e programmati vengono solo dagli archivi di PrivaPub.
+
+
+ Post localizzati
+
+
+ Fatto {0}; conservato fino al {1}.
+
+
+ Crea l'archivio
+
+
+ Silenziamenti
+
+
+ Regole delle notifiche
+
+
+ Post fissati
+
+
+ Post e le loro immagini
+
+
+ Profilo: nome, descrizione, campi, immagini
+
+
+ Ripristina un archivio in questa persona
+
+
+ Invio di {0} su {1}
+
+
+ Interrotto.
+
+
+ Non è stato possibile creare l'archivio: {0}
+
+
+ L'importazione non è riuscita: {0}
+
+
+ L'archivio di questa persona
+
+
+ In attesa di iniziare.
+
+
+ account bloccati
+
+
+ account silenziati
+
+
+ già presenti: {0}
+
+
+ segnalibri importati
+
+
+ condivisioni escluse
+
+
+ filtri importati
+
+
+ follow richiesti
+
+
+ hashtag seguiti
+
+
+ mi piace dati
+
+
+ membri aggiunti alle liste
+
+
+ membri delle liste non seguiti
+
+
+ liste create
+
+
+ post localizzati già presenti
+
+
+ post localizzati importati
+
+
+ non trovati: {0}
+
+
+ regole delle notifiche impostate
+
+
+ altri elementi esclusi
+
+
+ immagini e file importati
+
+
+ immagini assenti dall'archivio
+
+
+ immagini rifiutate
+
+
+ post già presenti
+
+
+ post di altri esclusi
+
+
+ post eliminati qui, lasciati eliminati
+
+
+ post importati
+
+
+ post fissati
+
+
+ post privati esclusi
+
+
+ profilo aggiornato
+
+
+ post programmati importati
+
+
+ post programmati scaduti o rifiutati
+
+
+ server bloccati
+
diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx
index 2cd80ce..04b6803 100644
--- a/Resources/AllStrings.resx
+++ b/Resources/AllStrings.resx
@@ -2081,4 +2081,169 @@
unreadable
+
+ An archive made here, or by Mastodon. Its posts arrive on this persona's profile without being sent to anyone; follows are asked again.
+
+
+ Blocks
+
+
+ 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.
+
+
+ Follows, asked again
+
+
+ From this persona's own archive.
+
+
+ From {0}.
+
+
+ Imported.
+
+
+ Importing: {0} of {1} read.
+
+
+ Likes, given again
+
+
+ Liking again tells each post's author; located and scheduled posts come from PrivaPub's archives only.
+
+
+ Located posts
+
+
+ Made {0}; kept until {1}.
+
+
+ Make the archive
+
+
+ Mutes
+
+
+ Notification policy
+
+
+ Pinned posts
+
+
+ Posts and their pictures
+
+
+ Profile: name, words, fields, pictures
+
+
+ Restore an archive into this persona
+
+
+ Sending {0} of {1}
+
+
+ Stopped.
+
+
+ The archive could not be made: {0}
+
+
+ The import failed: {0}
+
+
+ This persona's archive
+
+
+ Waiting to start.
+
+
+ accounts blocked
+
+
+ accounts muted
+
+
+ already there: {0}
+
+
+ bookmarks imported
+
+
+ boosts left out
+
+
+ filters imported
+
+
+ follows asked
+
+
+ hashtags followed
+
+
+ likes given
+
+
+ list members added
+
+
+ list members not followed
+
+
+ lists made
+
+
+ located posts already here
+
+
+ located posts imported
+
+
+ not found: {0}
+
+
+ notification policy set
+
+
+ other items left out
+
+
+ pictures and files imported
+
+
+ pictures not in the archive
+
+
+ pictures refused
+
+
+ posts already here
+
+
+ posts by others left out
+
+
+ posts deleted here, left deleted
+
+
+ posts imported
+
+
+ posts pinned
+
+
+ private posts left out
+
+
+ profile updated
+
+
+ scheduled posts imported
+
+
+ scheduled posts past or refused
+
+
+ servers blocked
+
diff --git a/Services/ArchiveService.cs b/Services/ArchiveService.cs
new file mode 100644
index 0000000..7d3d36e
--- /dev/null
+++ b/Services/ArchiveService.cs
@@ -0,0 +1,84 @@
+using System.Net;
+
+using collAnon.Client.Services;
+
+using decePubClient.Extensions;
+using decePubClient.Models;
+
+using PrivaPub.ClientModels;
+using PrivaPub.ClientModels.User;
+
+namespace decePubClient.Services
+{
+ ///
+ /// A persona's whole archive on PrivaPub (/clientapi/persona/{id}/archive): made in a job and downloaded through a
+ /// link, or another archive (its own, or Mastodon's) uploaded in pieces by js/transfer.js and imported into it, the
+ /// parts chosen, until it is done or asked to stop. As the root. Values null on failure; nothing throws.
+ ///
+ public class ArchiveService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
+ {
+ public async Task State(string personaId, CancellationToken cancellationToken = default)
+ {
+ using var response = await httpService.Get(APIs.ClientApi.GET_M_PersonaArchive(personaId), cancellationToken: cancellationToken);
+ if (response.IsSuccessStatusCode)
+ return await response.DefaultReadFromJsonAsync(cancellationToken);
+
+ var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
+ if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.NotFound))
+ await logger.ProcessWarning(result.ToString(localizer), $"{nameof(ArchiveService)}.{nameof(State)}", cancellationToken);
+ return null;
+ }
+
+ /// Asks for the archive to be made; Data is the state.
+ public async Task Export(string personaId, CancellationToken cancellationToken = default)
+ {
+ using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchive(personaId), cancellationToken: cancellationToken);
+ if (!response.IsSuccessStatusCode)
+ return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
+
+ return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) };
+ }
+
+ /// A link to download the archive made; Data is the ViewArchiveTicket.
+ public async Task Ticket(string personaId, CancellationToken cancellationToken = default)
+ {
+ using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchiveTicket(personaId), cancellationToken: cancellationToken);
+ if (!response.IsSuccessStatusCode)
+ return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
+
+ return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) };
+ }
+
+ /// An upload for an archive's pieces; Data is the ViewArchiveUpload.
+ public async Task StartUpload(CancellationToken cancellationToken = default)
+ {
+ using var response = await httpService.Post(APIs.ClientApi.POST_PersonaArchiveUpload, cancellationToken: cancellationToken);
+ if (!response.IsSuccessStatusCode)
+ return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
+
+ return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) };
+ }
+
+ public async Task CancelUpload(string uploadId, CancellationToken cancellationToken = default)
+ {
+ using var response = await httpService.Delete(APIs.ClientApi.DELETE_M_PersonaArchiveUpload(uploadId), cancellationToken: cancellationToken);
+ return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
+ }
+
+ /// The uploaded archive imported into the persona; Data is the state.
+ public async Task Import(string personaId, ImportPersonaArchiveForm form, CancellationToken cancellationToken = default)
+ {
+ using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchiveImport(personaId), form, cancellationToken: cancellationToken);
+ if (!response.IsSuccessStatusCode)
+ return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
+
+ return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) };
+ }
+
+ public async Task Stop(string personaId, CancellationToken cancellationToken = default)
+ {
+ using var response = await httpService.Post(APIs.ClientApi.POST_M_PersonaArchiveImportStop(personaId), cancellationToken: cancellationToken);
+ return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
+ }
+ }
+}
diff --git a/tests/e2e/Actions/PersonaArchiveTests.cs b/tests/e2e/Actions/PersonaArchiveTests.cs
new file mode 100644
index 0000000..6792b64
--- /dev/null
+++ b/tests/e2e/Actions/PersonaArchiveTests.cs
@@ -0,0 +1,82 @@
+using System.IO.Compression;
+
+using decePubClient.E2E.Support;
+
+using Microsoft.Playwright;
+
+namespace decePubClient.E2E.Actions
+{
+ // A persona's archive from Settings (owner decision 2026-10-07): one town persona's archive made and downloaded through
+ // its link, then imported into another, whose profile shows the posts while nobody was told; the copies are taken away
+ // again after, so the town stays as seeded
+ public sealed class PersonaArchiveTests(Browser browser)
+ {
+ static async Task Open(IPage page)
+ {
+ await page.GotoAsync($"{Pasture.Site}/settings");
+ var header = page.Locator(".collapse-title", new() { HasText = "Data import/export" }).First;
+ await header.ClickAsync();
+ var panel = header.Locator("xpath=..").Locator("[data-archive]");
+ await panel.WaitForAsync(new() { Timeout = 30000 });
+ return panel;
+ }
+
+ [Fact]
+ public async Task An_archive_is_downloaded_and_imported_into_another_persona()
+ {
+ var personas = Pasture.Personas();
+ var (source, target) = (personas[0], personas[1]);
+ var file = Path.Combine(Path.GetTempPath(), $"decepub-archive-{Guid.NewGuid():N}.zip");
+ try
+ {
+ await using (var session = await SignIn.As(browser, source))
+ {
+ var page = session.Page;
+ await Results.Cell("client.action.archive-export", "privapub", source.UserName, async () =>
+ {
+ var panel = await Open(page);
+ await panel.Locator("[data-archive-make]").ClickAsync();
+ var get = panel.Locator("[data-archive-get-link]");
+ await get.WaitForAsync(new() { Timeout = 120000 });
+ await get.ClickAsync();
+ var link = panel.Locator("[data-archive-link]");
+ var download = await page.RunAndWaitForDownloadAsync(() => link.ClickAsync(), new() { Timeout = 120000 });
+ await download.SaveAsAsync(file);
+ using var archive = ZipFile.OpenRead(file);
+ Assert.Contains(archive.Entries, e => e.FullName == "outbox.json");
+ Assert.Contains(archive.Entries, e => e.FullName == "actor.json");
+ Assert.DoesNotContain(archive.Entries, e => e.FullName.Contains(source.Root, StringComparison.Ordinal));
+ await session.AssertGuards();
+ }, page);
+ }
+
+ await using (var session = await SignIn.As(browser, target))
+ {
+ var page = session.Page;
+ await Results.Cell("client.action.archive-import", "privapub", target.UserName, async () =>
+ {
+ var panel = await Open(page);
+ await panel.Locator("input[data-archive-file]").SetInputFilesAsync(file);
+ foreach (var part in new[] { "follows", "blocks", "mutes", "domainblocks", "lists", "bookmarks", "filters", "tags", "pins" })
+ await panel.Locator($"[data-archive-part='{part}'] input, input[data-archive-part='{part}']").First.UncheckAsync();
+ await panel.Locator("[data-archive-import-start]").ClickAsync();
+ var report = panel.Locator("[data-archive-report][data-archive-state='done']");
+ await report.WaitForAsync(new() { Timeout = 300000 });
+ await Assertions.Expect(report.Locator("[data-archive-count='posts']")).ToBeVisibleAsync();
+
+ var copies = (await PersonaApi.Call(target, $"/api/v1/accounts/{target.LocalId}/statuses?limit=40"))!.AsArray();
+ Assert.NotEmpty(copies);
+ await session.AssertGuards();
+ }, page);
+ }
+ }
+ finally
+ {
+ File.Delete(file);
+ // the copies go, as if never imported: the town stays as seeded
+ await Pasture.Mongo($"const copies = db.Post.find({{GroupUserId:'{target.LocalId}', ImportedFromURI:{{$exists:true, $ne:null}}}}, {{_id:1}}).toArray().map(p => p._id); "
+ + "db.MediaAttachment.deleteMany({PostId:{$in:copies.map(id => id.toString())}}); db.Post.deleteMany({_id:{$in:copies}})");
+ }
+ }
+ }
+}