An import reads the archive's followers once, finds threads in linear time, and imports a located post once

A post's visibility no longer parses actor.json again for every outbox item; a reply's conversation is its thread's
first post's, found from what the first pass read instead of searching every planned post (quadratic on a large
archive); and a located post always records where it came from, so importing a persona's own archive twice made it
twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-07 12:51:58 +02:00
1 parent 44a5895b0c
commit 163f7e4e2b
2 files changed
+36 -17

No files matched your search

+26 -11
View File
@@ -65,15 +65,18 @@ namespace PrivaPub.Domain.Portability
DedupeKey = $"import|{avatarId}|{DateTime.UtcNow.Ticks}"
};
sealed class Run(PersonaArchive state, LocalActor me, SafeArchive archive, string archiveActor, bool sameActor, IServiceProvider services)
sealed class Run(PersonaArchive state, LocalActor me, SafeArchive archive, string archiveActor, string archiveFollowers, bool sameActor,
IServiceProvider services)
{
public PersonaArchive State { get; } = state;
public string ArchiveFollowers { get; } = archiveFollowers;
public LocalActor Me { get; } = me;
public SafeArchive Archive { get; } = archive;
public string ArchiveActor { get; } = archiveActor;
public bool SameActor { get; } = sameActor;
public IServiceProvider Services { get; } = services;
public Dictionary<string, (string Id, string Uri)> Posts { get; } = new(StringComparer.Ordinal);//archive object id → here
public Dictionary<string, string> Parents { get; } = new(StringComparer.Ordinal);//archive object id → what it answers
public DateTime LastSaved { get; set; }
public void Count(string what, int by = 1) => State.ImportCounts[what] = State.ImportCounts.GetValueOrDefault(what) + by;
@@ -109,7 +112,8 @@ namespace PrivaPub.Domain.Portability
return JobOutcome.Done;
}
using var scope = scopes.CreateScope();
var run = new Run(state, me, archive, actorId, actorId == me.Uri, scope.ServiceProvider);
var run = new Run(state, me, archive, actorId, Text(actor["followers"]) ?? actorId.TrimEnd('/') + "/followers", actorId == me.Uri,
scope.ServiceProvider);
state.ImportState = "running";
state.ImportFrom = actorId;
state.ImportSameActor = run.SameActor;
@@ -224,10 +228,7 @@ namespace PrivaPub.Domain.Portability
return PostVisibility.Public;
if (cc.Any(Public.Contains))
return PostVisibility.Unlisted;
var followers = run.ArchiveActor.TrimEnd('/') is var actor && run.Archive.Json("actor.json") is JsonObject document
? Text(document["followers"]) ?? actor + "/followers"
: default;
if (followers != default && to.Concat(cc).Contains(followers) && Text(note["audience"]) == default)
if (to.Concat(cc).Contains(run.ArchiveFollowers) && Text(note["audience"]) == default)
return PostVisibility.FollowersOnly;
return default;
}
@@ -260,6 +261,8 @@ namespace PrivaPub.Domain.Portability
continue;
var original = Text(note["id"]);
var published = Date(note["published"]) ?? DateTime.UtcNow;
if (Id(note["inReplyTo"]) is { } answers)
run.Parents[original] = answers;
if (run.SameActor)
{
var id = original.TrimEnd('/').Split('/')[^1];
@@ -363,10 +366,9 @@ namespace PrivaPub.Domain.Portability
}
if (Text(note["type"]) == "Question")
post.Poll = Poll(note);
if (visibility is PostVisibility.Public or PostVisibility.Unlisted)
post.ContextURI = post.AnsweringToPostId != default && run.Posts.ContainsValue((post.AnsweringToPostId, post.InReplyToURI))
? post.InReplyToURI + "/context"
: post.ObjectURI + "/context";
// the conversation (FEP-7888) of its thread's first post, when the thread starts in the archive
if (visibility is PostVisibility.Public or PostVisibility.Unlisted && Root(run, original) is { } root && run.Posts.TryGetValue(root, out var first))
post.ContextURI = first.Uri + "/context";
post.Media = await Media(run, note["attachment"] as JsonArray, id, token);
var rendered = ActivityPubRenderer.Note(post, me, default, post.InReplyToURI);
post.To = Strings(rendered["to"]);
@@ -376,6 +378,19 @@ namespace PrivaPub.Domain.Portability
await DB.Default.Update<PostEntity>().MatchID(post.AnsweringToPostId).Modify(b => b.Inc(p => p.RepliesCount, 1)).ExecuteAsync(token);
}
// the first post of an archive post's thread, when the thread starts in the archive
static string Root(Run run, string original)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
while (run.Parents.TryGetValue(original, out var parent) && seen.Add(original))
{
if (!run.Posts.ContainsKey(parent))
return default;
original = parent;
}
return original;
}
static PostPoll Poll(JsonObject note)
{
var multiple = note["anyOf"] is JsonArray;
@@ -490,7 +505,7 @@ namespace PrivaPub.Domain.Portability
RangeKm = (float)Math.Clamp(located["rangeKm"]?.GetValue<double>() ?? 5, 1, 50),
ObjectURI = run.Me.PostUri(id),
Url = run.Me.PostHtmlUrl(id),
ImportedFromURI = original == run.Me.PostUri(original?.Split('/')[^1] ?? string.Empty) ? default : original,
ImportedFromURI = original,
CreationDate = Clamp(Date(located["published"]) ?? DateTime.UtcNow)
};
var files = (located["media"] as JsonArray ?? []).OfType<JsonObject>()