The composer uploads each file as soon as it is attached (streamed from the browser file, under its own ten-minute
timeout), shows it uploading, processing (a 202 followed until ready), ready or failed with Retry, and cancels one
removed; publishing sends the ids, so a failed publish or a retried upload sends nothing twice. Size limits and types
come from /api/v2/instance, a file without a browser type gets one from its extension, and each refusal names the file
and why. Previews are 320 px thumbnails, not whole files in memory. A picture without a description is asked about once
("Publish anyway"; a setting turns it off), descriptions are sent with PUT, and an edit keeps the post's media, language
and sensitive flag.
Profile pictures are checked against the server's types and can be removed. A card shows "Media unavailable" with the
original's link when a picture fails, the blurhash behind a sensitive placeholder, and plays a GIF only on tap under
reduced motion. A failed export says so instead of downloading part of a file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
157 lines
5.1 KiB
Plaintext
157 lines
5.1 KiB
Plaintext
@inherits LocalizableComponentBase
|
|
|
|
@* The persona's follows, lists, blocks, mutes, blocked servers and bookmarks, exported and imported as the CSV files
|
|
Mastodon writes, which most fediverse servers read too: a persona moves here from another server, or away, with what
|
|
it gathered there. An import acts one row at a time, through PrivaPub, and can be stopped. *@
|
|
<div class="flex flex-col gap-5 w-full">
|
|
<section class="flex flex-col gap-2" data-export>
|
|
<h3 class="font-semibold text-sm">@Localizer["Export"]</h3>
|
|
<p class="text-sm opacity-70">@Localizer["Files in Mastodon's format, which most fediverse servers import."]</p>
|
|
<div class="flex flex-wrap gap-2">
|
|
@foreach (var data in Kinds)
|
|
{
|
|
<DButton Size="DSize.Sm" Disabled="@(IsDefaultDisabled || exporting is not null || importing is not null)" OnClick="() => Export(data)"
|
|
data-export-kind="@data">
|
|
@if (exporting == data)
|
|
{
|
|
<DLoading Size="DSize.Xs" />
|
|
}
|
|
else
|
|
{
|
|
<i class="ion-md-download"></i>
|
|
}
|
|
<span>@Name(data)</span>
|
|
</DButton>
|
|
}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="flex flex-col gap-2" data-import>
|
|
<h3 class="font-semibold text-sm">@Localizer["Import"]</h3>
|
|
<div class="flex flex-wrap gap-2 items-end">
|
|
<label class="flex flex-col gap-1 text-sm">
|
|
<span>@Localizer["What the file holds"]</span>
|
|
<DSelect TValue="PortableData" Size="DSize.Sm" @bind-Value="importKind" disabled="@(importing is not null)" data-import-kind>
|
|
@foreach (var data in Kinds)
|
|
{
|
|
<option value="@data">@Name(data)</option>
|
|
}
|
|
</DSelect>
|
|
</label>
|
|
<DFileInput Size="DSize.Sm" Accept=".csv,text/csv" OnChange="Pick" disabled="@(importing is not null)" data-import-file />
|
|
@if (importing is null)
|
|
{
|
|
<DButton Size="DSize.Sm" Color="DColor.Primary" Disabled="@(IsDefaultDisabled || file is null || exporting is not null)" OnClick="Import"
|
|
data-import-start>
|
|
<i class="ion-md-cloud-upload"></i><span>@Localizer["Import"]</span>
|
|
</DButton>
|
|
}
|
|
else
|
|
{
|
|
<DButton Size="DSize.Sm" Color="DColor.Warning" OnClick="Stop">@Localizer["Stop"]</DButton>
|
|
}
|
|
</div>
|
|
@if (report is not null)
|
|
{
|
|
<div class="flex flex-col gap-1" data-import-report>
|
|
<DProgress class="w-full max-w-md" Value="@(report.Done + report.Failed.Count)" Max="@Math.Max(1, report.Total)" />
|
|
<span class="text-sm tabular-nums">
|
|
@Localizer["{0} of {1} done, {2} could not be.", report.Done, report.Total, report.Failed.Count]
|
|
</span>
|
|
<ErrorViewer AdditionalErrors="report.Failed" />
|
|
</div>
|
|
}
|
|
</section>
|
|
</div>
|
|
|
|
@code {
|
|
[Inject] PortabilityService PortabilityService { get; set; }
|
|
[Inject] IBlazorDownloadFileService DownloadFile { get; set; }
|
|
[Inject] ILoggingService Logger { get; set; }
|
|
|
|
// an export file of 5000 rows stays well under this
|
|
const long MaximumFileSize = 4 * 1024 * 1024;
|
|
static readonly PortableData[] Kinds = Enum.GetValues<PortableData>();
|
|
|
|
PortableData? exporting;
|
|
PortableData? importing;
|
|
PortableData importKind = PortableData.Follows;
|
|
string file;
|
|
ImportReport report;
|
|
CancellationTokenSource stopping;
|
|
|
|
protected override void OnInitialized() => IsLoading = false;
|
|
|
|
string Name(PortableData data) => data switch
|
|
{
|
|
PortableData.Follows => Localizer["Follows"],
|
|
PortableData.Lists => Localizer["Lists"],
|
|
PortableData.Blocks => Localizer["Blocked accounts"],
|
|
PortableData.Mutes => Localizer["Muted accounts"],
|
|
PortableData.ServerBlocks => Localizer["Blocked servers"],
|
|
_ => Localizer["Bookmarks"]
|
|
};
|
|
|
|
async Task Export(PortableData data)
|
|
{
|
|
exporting = data;
|
|
var result = await PortabilityService.Export(data, CascadingState.PersonaId);
|
|
exporting = null;
|
|
if (result.IsInvalid)
|
|
{
|
|
Toast.AddResponseError(result);
|
|
return;
|
|
}
|
|
|
|
await DownloadFile.DownloadFileAsync(PortabilityService.FileName(data), (byte[])result.Data, "text/csv");
|
|
}
|
|
|
|
async Task Pick(InputFileChangeEventArgs eventArgs)
|
|
{
|
|
file = null;
|
|
report = null;
|
|
if (eventArgs.File.Size > MaximumFileSize)
|
|
{
|
|
Toast.AddWarning(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", eventArgs.File.Name,
|
|
eventArgs.File.Size.GetFileSize(Localizer), MaximumFileSize.GetFileSize(Localizer)]);
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
using var reader = new StreamReader(eventArgs.File.OpenReadStream(MaximumFileSize));
|
|
file = await reader.ReadToEndAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
await Logger.ProcessError(ex, $"{nameof(DataTransfer)}.{nameof(Pick)}:{eventArgs.File.Name}");
|
|
Toast.AddWarning(Localizer["The file \"{0}\" could not be read.", eventArgs.File.Name]);
|
|
}
|
|
}
|
|
|
|
async Task Import()
|
|
{
|
|
importing = importKind;
|
|
stopping = new();
|
|
report = new();
|
|
var progress = new Progress<ImportReport>(step =>
|
|
{
|
|
report = step;
|
|
StateHasChanged();
|
|
});
|
|
report = await PortabilityService.Import(importKind, file, progress, stopping.Token);
|
|
importing = null;
|
|
stopping.Dispose();
|
|
stopping = null;
|
|
Toast.AddResponseSuccess(Localizer["{0} of {1} done, {2} could not be.", report.Done, report.Total, report.Failed.Count]);
|
|
}
|
|
|
|
void Stop() => stopping?.Cancel();
|
|
|
|
public override async ValueTask DisposeAsync()
|
|
{
|
|
stopping?.Cancel();
|
|
await base.DisposeAsync();
|
|
}
|
|
}
|