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
This commit is contained in:
1 parent
dc2190e271
commit
48df316256
10 files changed
+882
-2
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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. *@
|
||||
<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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -133,7 +133,10 @@
|
||||
<div class="block w-full p-3 md:p-4">
|
||||
@if (CascadingState.IsSignedIn)
|
||||
{
|
||||
<DataTransfer />
|
||||
<div class="flex flex-col gap-6">
|
||||
<PersonaArchivePanel />
|
||||
<DataTransfer />
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -45,6 +45,7 @@ builder.Services.AddOptions()
|
||||
.AddScoped<AccountService>()
|
||||
.AddScoped<PortabilityService>()
|
||||
.AddScoped<AdminService>()
|
||||
.AddScoped<ArchiveService>()
|
||||
.AddScoped<ListService>()
|
||||
.AddScoped<FilterService>()
|
||||
.AddScoped<DiscoveryService>()
|
||||
|
||||
@@ -2081,4 +2081,169 @@
|
||||
<data name="unreadable" xml:space="preserve">
|
||||
<value>illeggibile</value>
|
||||
</data>
|
||||
<data name="An archive made here, or by Mastodon. Its posts arrive on this persona's profile without being sent to anyone; follows are asked again." xml:space="preserve">
|
||||
<value>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.</value>
|
||||
</data>
|
||||
<data name="Blocks" xml:space="preserve">
|
||||
<value>Blocchi</value>
|
||||
</data>
|
||||
<data name="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." xml:space="preserve">
|
||||
<value>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.</value>
|
||||
</data>
|
||||
<data name="Follows, asked again" xml:space="preserve">
|
||||
<value>Follow, richiesti di nuovo</value>
|
||||
</data>
|
||||
<data name="From this persona's own archive." xml:space="preserve">
|
||||
<value>Dall'archivio di questa stessa persona.</value>
|
||||
</data>
|
||||
<data name="From {0}." xml:space="preserve">
|
||||
<value>Da {0}.</value>
|
||||
</data>
|
||||
<data name="Imported." xml:space="preserve">
|
||||
<value>Importato.</value>
|
||||
</data>
|
||||
<data name="Importing: {0} of {1} read." xml:space="preserve">
|
||||
<value>Importazione: {0} di {1} letti.</value>
|
||||
</data>
|
||||
<data name="Likes, given again" xml:space="preserve">
|
||||
<value>Mi piace, dati di nuovo</value>
|
||||
</data>
|
||||
<data name="Liking again tells each post's author; located and scheduled posts come from PrivaPub's archives only." xml:space="preserve">
|
||||
<value>Rimettere i mi piace lo fa sapere all'autore di ogni post; i post localizzati e programmati vengono solo dagli archivi di PrivaPub.</value>
|
||||
</data>
|
||||
<data name="Located posts" xml:space="preserve">
|
||||
<value>Post localizzati</value>
|
||||
</data>
|
||||
<data name="Made {0}; kept until {1}." xml:space="preserve">
|
||||
<value>Fatto {0}; conservato fino al {1}.</value>
|
||||
</data>
|
||||
<data name="Make the archive" xml:space="preserve">
|
||||
<value>Crea l'archivio</value>
|
||||
</data>
|
||||
<data name="Mutes" xml:space="preserve">
|
||||
<value>Silenziamenti</value>
|
||||
</data>
|
||||
<data name="Notification policy" xml:space="preserve">
|
||||
<value>Regole delle notifiche</value>
|
||||
</data>
|
||||
<data name="Pinned posts" xml:space="preserve">
|
||||
<value>Post fissati</value>
|
||||
</data>
|
||||
<data name="Posts and their pictures" xml:space="preserve">
|
||||
<value>Post e le loro immagini</value>
|
||||
</data>
|
||||
<data name="Profile: name, words, fields, pictures" xml:space="preserve">
|
||||
<value>Profilo: nome, descrizione, campi, immagini</value>
|
||||
</data>
|
||||
<data name="Restore an archive into this persona" xml:space="preserve">
|
||||
<value>Ripristina un archivio in questa persona</value>
|
||||
</data>
|
||||
<data name="Sending {0} of {1}" xml:space="preserve">
|
||||
<value>Invio di {0} su {1}</value>
|
||||
</data>
|
||||
<data name="Stopped." xml:space="preserve">
|
||||
<value>Interrotto.</value>
|
||||
</data>
|
||||
<data name="The archive could not be made: {0}" xml:space="preserve">
|
||||
<value>Non è stato possibile creare l'archivio: {0}</value>
|
||||
</data>
|
||||
<data name="The import failed: {0}" xml:space="preserve">
|
||||
<value>L'importazione non è riuscita: {0}</value>
|
||||
</data>
|
||||
<data name="This persona's archive" xml:space="preserve">
|
||||
<value>L'archivio di questa persona</value>
|
||||
</data>
|
||||
<data name="Waiting to start." xml:space="preserve">
|
||||
<value>In attesa di iniziare.</value>
|
||||
</data>
|
||||
<data name="accounts blocked" xml:space="preserve">
|
||||
<value>account bloccati</value>
|
||||
</data>
|
||||
<data name="accounts muted" xml:space="preserve">
|
||||
<value>account silenziati</value>
|
||||
</data>
|
||||
<data name="already there: {0}" xml:space="preserve">
|
||||
<value>già presenti: {0}</value>
|
||||
</data>
|
||||
<data name="bookmarks imported" xml:space="preserve">
|
||||
<value>segnalibri importati</value>
|
||||
</data>
|
||||
<data name="boosts left out" xml:space="preserve">
|
||||
<value>condivisioni escluse</value>
|
||||
</data>
|
||||
<data name="filters imported" xml:space="preserve">
|
||||
<value>filtri importati</value>
|
||||
</data>
|
||||
<data name="follows asked" xml:space="preserve">
|
||||
<value>follow richiesti</value>
|
||||
</data>
|
||||
<data name="hashtags followed" xml:space="preserve">
|
||||
<value>hashtag seguiti</value>
|
||||
</data>
|
||||
<data name="likes given" xml:space="preserve">
|
||||
<value>mi piace dati</value>
|
||||
</data>
|
||||
<data name="list members added" xml:space="preserve">
|
||||
<value>membri aggiunti alle liste</value>
|
||||
</data>
|
||||
<data name="list members not followed" xml:space="preserve">
|
||||
<value>membri delle liste non seguiti</value>
|
||||
</data>
|
||||
<data name="lists made" xml:space="preserve">
|
||||
<value>liste create</value>
|
||||
</data>
|
||||
<data name="located posts already here" xml:space="preserve">
|
||||
<value>post localizzati già presenti</value>
|
||||
</data>
|
||||
<data name="located posts imported" xml:space="preserve">
|
||||
<value>post localizzati importati</value>
|
||||
</data>
|
||||
<data name="not found: {0}" xml:space="preserve">
|
||||
<value>non trovati: {0}</value>
|
||||
</data>
|
||||
<data name="notification policy set" xml:space="preserve">
|
||||
<value>regole delle notifiche impostate</value>
|
||||
</data>
|
||||
<data name="other items left out" xml:space="preserve">
|
||||
<value>altri elementi esclusi</value>
|
||||
</data>
|
||||
<data name="pictures and files imported" xml:space="preserve">
|
||||
<value>immagini e file importati</value>
|
||||
</data>
|
||||
<data name="pictures not in the archive" xml:space="preserve">
|
||||
<value>immagini assenti dall'archivio</value>
|
||||
</data>
|
||||
<data name="pictures refused" xml:space="preserve">
|
||||
<value>immagini rifiutate</value>
|
||||
</data>
|
||||
<data name="posts already here" xml:space="preserve">
|
||||
<value>post già presenti</value>
|
||||
</data>
|
||||
<data name="posts by others left out" xml:space="preserve">
|
||||
<value>post di altri esclusi</value>
|
||||
</data>
|
||||
<data name="posts deleted here, left deleted" xml:space="preserve">
|
||||
<value>post eliminati qui, lasciati eliminati</value>
|
||||
</data>
|
||||
<data name="posts imported" xml:space="preserve">
|
||||
<value>post importati</value>
|
||||
</data>
|
||||
<data name="posts pinned" xml:space="preserve">
|
||||
<value>post fissati</value>
|
||||
</data>
|
||||
<data name="private posts left out" xml:space="preserve">
|
||||
<value>post privati esclusi</value>
|
||||
</data>
|
||||
<data name="profile updated" xml:space="preserve">
|
||||
<value>profilo aggiornato</value>
|
||||
</data>
|
||||
<data name="scheduled posts imported" xml:space="preserve">
|
||||
<value>post programmati importati</value>
|
||||
</data>
|
||||
<data name="scheduled posts past or refused" xml:space="preserve">
|
||||
<value>post programmati scaduti o rifiutati</value>
|
||||
</data>
|
||||
<data name="servers blocked" xml:space="preserve">
|
||||
<value>server bloccati</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -2081,4 +2081,169 @@
|
||||
<data name="unreadable" xml:space="preserve">
|
||||
<value>unreadable</value>
|
||||
</data>
|
||||
<data name="An archive made here, or by Mastodon. Its posts arrive on this persona's profile without being sent to anyone; follows are asked again." xml:space="preserve">
|
||||
<value>An archive made here, or by Mastodon. Its posts arrive on this persona's profile without being sent to anyone; follows are asked again.</value>
|
||||
</data>
|
||||
<data name="Blocks" xml:space="preserve">
|
||||
<value>Blocks</value>
|
||||
</data>
|
||||
<data name="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." xml:space="preserve">
|
||||
<value>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.</value>
|
||||
</data>
|
||||
<data name="Follows, asked again" xml:space="preserve">
|
||||
<value>Follows, asked again</value>
|
||||
</data>
|
||||
<data name="From this persona's own archive." xml:space="preserve">
|
||||
<value>From this persona's own archive.</value>
|
||||
</data>
|
||||
<data name="From {0}." xml:space="preserve">
|
||||
<value>From {0}.</value>
|
||||
</data>
|
||||
<data name="Imported." xml:space="preserve">
|
||||
<value>Imported.</value>
|
||||
</data>
|
||||
<data name="Importing: {0} of {1} read." xml:space="preserve">
|
||||
<value>Importing: {0} of {1} read.</value>
|
||||
</data>
|
||||
<data name="Likes, given again" xml:space="preserve">
|
||||
<value>Likes, given again</value>
|
||||
</data>
|
||||
<data name="Liking again tells each post's author; located and scheduled posts come from PrivaPub's archives only." xml:space="preserve">
|
||||
<value>Liking again tells each post's author; located and scheduled posts come from PrivaPub's archives only.</value>
|
||||
</data>
|
||||
<data name="Located posts" xml:space="preserve">
|
||||
<value>Located posts</value>
|
||||
</data>
|
||||
<data name="Made {0}; kept until {1}." xml:space="preserve">
|
||||
<value>Made {0}; kept until {1}.</value>
|
||||
</data>
|
||||
<data name="Make the archive" xml:space="preserve">
|
||||
<value>Make the archive</value>
|
||||
</data>
|
||||
<data name="Mutes" xml:space="preserve">
|
||||
<value>Mutes</value>
|
||||
</data>
|
||||
<data name="Notification policy" xml:space="preserve">
|
||||
<value>Notification policy</value>
|
||||
</data>
|
||||
<data name="Pinned posts" xml:space="preserve">
|
||||
<value>Pinned posts</value>
|
||||
</data>
|
||||
<data name="Posts and their pictures" xml:space="preserve">
|
||||
<value>Posts and their pictures</value>
|
||||
</data>
|
||||
<data name="Profile: name, words, fields, pictures" xml:space="preserve">
|
||||
<value>Profile: name, words, fields, pictures</value>
|
||||
</data>
|
||||
<data name="Restore an archive into this persona" xml:space="preserve">
|
||||
<value>Restore an archive into this persona</value>
|
||||
</data>
|
||||
<data name="Sending {0} of {1}" xml:space="preserve">
|
||||
<value>Sending {0} of {1}</value>
|
||||
</data>
|
||||
<data name="Stopped." xml:space="preserve">
|
||||
<value>Stopped.</value>
|
||||
</data>
|
||||
<data name="The archive could not be made: {0}" xml:space="preserve">
|
||||
<value>The archive could not be made: {0}</value>
|
||||
</data>
|
||||
<data name="The import failed: {0}" xml:space="preserve">
|
||||
<value>The import failed: {0}</value>
|
||||
</data>
|
||||
<data name="This persona's archive" xml:space="preserve">
|
||||
<value>This persona's archive</value>
|
||||
</data>
|
||||
<data name="Waiting to start." xml:space="preserve">
|
||||
<value>Waiting to start.</value>
|
||||
</data>
|
||||
<data name="accounts blocked" xml:space="preserve">
|
||||
<value>accounts blocked</value>
|
||||
</data>
|
||||
<data name="accounts muted" xml:space="preserve">
|
||||
<value>accounts muted</value>
|
||||
</data>
|
||||
<data name="already there: {0}" xml:space="preserve">
|
||||
<value>already there: {0}</value>
|
||||
</data>
|
||||
<data name="bookmarks imported" xml:space="preserve">
|
||||
<value>bookmarks imported</value>
|
||||
</data>
|
||||
<data name="boosts left out" xml:space="preserve">
|
||||
<value>boosts left out</value>
|
||||
</data>
|
||||
<data name="filters imported" xml:space="preserve">
|
||||
<value>filters imported</value>
|
||||
</data>
|
||||
<data name="follows asked" xml:space="preserve">
|
||||
<value>follows asked</value>
|
||||
</data>
|
||||
<data name="hashtags followed" xml:space="preserve">
|
||||
<value>hashtags followed</value>
|
||||
</data>
|
||||
<data name="likes given" xml:space="preserve">
|
||||
<value>likes given</value>
|
||||
</data>
|
||||
<data name="list members added" xml:space="preserve">
|
||||
<value>list members added</value>
|
||||
</data>
|
||||
<data name="list members not followed" xml:space="preserve">
|
||||
<value>list members not followed</value>
|
||||
</data>
|
||||
<data name="lists made" xml:space="preserve">
|
||||
<value>lists made</value>
|
||||
</data>
|
||||
<data name="located posts already here" xml:space="preserve">
|
||||
<value>located posts already here</value>
|
||||
</data>
|
||||
<data name="located posts imported" xml:space="preserve">
|
||||
<value>located posts imported</value>
|
||||
</data>
|
||||
<data name="not found: {0}" xml:space="preserve">
|
||||
<value>not found: {0}</value>
|
||||
</data>
|
||||
<data name="notification policy set" xml:space="preserve">
|
||||
<value>notification policy set</value>
|
||||
</data>
|
||||
<data name="other items left out" xml:space="preserve">
|
||||
<value>other items left out</value>
|
||||
</data>
|
||||
<data name="pictures and files imported" xml:space="preserve">
|
||||
<value>pictures and files imported</value>
|
||||
</data>
|
||||
<data name="pictures not in the archive" xml:space="preserve">
|
||||
<value>pictures not in the archive</value>
|
||||
</data>
|
||||
<data name="pictures refused" xml:space="preserve">
|
||||
<value>pictures refused</value>
|
||||
</data>
|
||||
<data name="posts already here" xml:space="preserve">
|
||||
<value>posts already here</value>
|
||||
</data>
|
||||
<data name="posts by others left out" xml:space="preserve">
|
||||
<value>posts by others left out</value>
|
||||
</data>
|
||||
<data name="posts deleted here, left deleted" xml:space="preserve">
|
||||
<value>posts deleted here, left deleted</value>
|
||||
</data>
|
||||
<data name="posts imported" xml:space="preserve">
|
||||
<value>posts imported</value>
|
||||
</data>
|
||||
<data name="posts pinned" xml:space="preserve">
|
||||
<value>posts pinned</value>
|
||||
</data>
|
||||
<data name="private posts left out" xml:space="preserve">
|
||||
<value>private posts left out</value>
|
||||
</data>
|
||||
<data name="profile updated" xml:space="preserve">
|
||||
<value>profile updated</value>
|
||||
</data>
|
||||
<data name="scheduled posts imported" xml:space="preserve">
|
||||
<value>scheduled posts imported</value>
|
||||
</data>
|
||||
<data name="scheduled posts past or refused" xml:space="preserve">
|
||||
<value>scheduled posts past or refused</value>
|
||||
</data>
|
||||
<data name="servers blocked" xml:space="preserve">
|
||||
<value>servers blocked</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class ArchiveService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer)
|
||||
{
|
||||
public async Task<ViewPersonaArchive> 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<ViewPersonaArchive>(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;
|
||||
}
|
||||
|
||||
/// <summary>Asks for the archive to be made; Data is the state.</summary>
|
||||
public async Task<WebResult> 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<ViewPersonaArchive>(cancellationToken) };
|
||||
}
|
||||
|
||||
/// <summary>A link to download the archive made; Data is the ViewArchiveTicket.</summary>
|
||||
public async Task<WebResult> 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<ViewArchiveTicket>(cancellationToken) };
|
||||
}
|
||||
|
||||
/// <summary>An upload for an archive's pieces; Data is the ViewArchiveUpload.</summary>
|
||||
public async Task<WebResult> 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<ViewArchiveUpload>(cancellationToken) };
|
||||
}
|
||||
|
||||
public async Task<WebResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>The uploaded archive imported into the persona; Data is the state.</summary>
|
||||
public async Task<WebResult> 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<ViewPersonaArchive>(cancellationToken) };
|
||||
}
|
||||
|
||||
public async Task<WebResult> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ILocator> 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}})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user