A remote post's canReply, canLike and canAnnounce (with the older always and approvalRequired) are kept beside canQuote and judged for each persona: let in at once when the rule names the public, the persona, the author's followers while it follows the author, or the accounts the author follows while the author follows it; asked first when only the manual list names it; refused (422) otherwise. Asked first, a ReplyRequest, LikeRequest or AnnounceRequest with the interaction as its instrument goes to the author alone, and the interaction waits (privapub.approval: pending). The author's Accept brings an authorization, verified on the author's origin as naming the interaction and the post; the reply then goes out with replyAuthorization, the boost with announceAuthorization, the like with likeAuthorization. A Reject leaves the reply ours alone and takes a like or a boost back. As a third party, a reply a policy does not let in at once is kept only with an authorization that verifies. Clients see the rules as GoToSocial's interaction_policy. Checked live against GoToSocial 0.22.1: the scenario's nine new checks pass (64 in all), a reply and a like approved through its interaction requests and a boost refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
443 lines
18 KiB
C#
443 lines
18 KiB
C#
using Markdig;
|
|
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Models.Federation;
|
|
using PrivaPub.Models.Post;
|
|
|
|
using System.Globalization;
|
|
using System.Net;
|
|
using System.Text.Json.Nodes;
|
|
|
|
using PostEntity = PrivaPub.Models.Post.Post;
|
|
|
|
namespace PrivaPub.Federation.Rendering
|
|
{
|
|
public static class ActivityPubRenderer
|
|
{
|
|
public const string ActivityStreams = "https://www.w3.org/ns/activitystreams";
|
|
public const string Public = Addressing.Public;
|
|
public const string ContentWarning = "Content warning";
|
|
|
|
static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().DisableHtml().Build();
|
|
|
|
public static JsonArray Context() => new(
|
|
ActivityStreams,
|
|
"https://w3id.org/security/v1",
|
|
new JsonObject
|
|
{
|
|
["manuallyApprovesFollowers"] = "as:manuallyApprovesFollowers",
|
|
["sensitive"] = "as:sensitive",
|
|
["Hashtag"] = "as:Hashtag",
|
|
["alsoKnownAs"] = new JsonObject { ["@id"] = "as:alsoKnownAs", ["@type"] = "@id" },
|
|
["movedTo"] = new JsonObject { ["@id"] = "as:movedTo", ["@type"] = "@id" },
|
|
["toot"] = "http://joinmastodon.org/ns#",
|
|
["featured"] = new JsonObject { ["@id"] = "toot:featured", ["@type"] = "@id" },
|
|
["featuredTags"] = new JsonObject { ["@id"] = "toot:featuredTags", ["@type"] = "@id" },
|
|
["discoverable"] = "toot:discoverable",
|
|
["indexable"] = "toot:indexable",
|
|
["blurhash"] = "toot:blurhash",
|
|
["votersCount"] = "toot:votersCount",
|
|
["Emoji"] = "toot:Emoji",
|
|
["quote"] = new JsonObject { ["@id"] = "https://w3id.org/fep/044f#quote", ["@type"] = "@id" },
|
|
["quoteAuthorization"] = new JsonObject { ["@id"] = "https://w3id.org/fep/044f#quoteAuthorization", ["@type"] = "@id" },
|
|
["QuoteRequest"] = "https://w3id.org/fep/044f#QuoteRequest",
|
|
["QuoteAuthorization"] = "https://w3id.org/fep/044f#QuoteAuthorization",
|
|
["interactingObject"] = new JsonObject { ["@id"] = "gts:interactingObject", ["@type"] = "@id" },
|
|
["interactionTarget"] = new JsonObject { ["@id"] = "gts:interactionTarget", ["@type"] = "@id" },
|
|
["misskey"] = "https://misskey-hub.net/ns#",
|
|
["_misskey_quote"] = "misskey:_misskey_quote",
|
|
["fedibird"] = "http://fedibird.com/ns#",
|
|
["quoteUri"] = "fedibird:quoteUri",
|
|
["quoteUrl"] = "as:quoteUrl",
|
|
["gts"] = "https://gotosocial.org/ns#",
|
|
["interactionPolicy"] = new JsonObject { ["@id"] = "gts:interactionPolicy", ["@type"] = "@id" },
|
|
["canQuote"] = new JsonObject { ["@id"] = "gts:canQuote", ["@type"] = "@id" },
|
|
["automaticApproval"] = new JsonObject { ["@id"] = "gts:automaticApproval", ["@type"] = "@id" },
|
|
["manualApproval"] = new JsonObject { ["@id"] = "gts:manualApproval", ["@type"] = "@id" },
|
|
["LikeRequest"] = "gts:LikeRequest",
|
|
["ReplyRequest"] = "gts:ReplyRequest",
|
|
["AnnounceRequest"] = "gts:AnnounceRequest",
|
|
["LikeAuthorization"] = "gts:LikeApproval",
|
|
["ReplyAuthorization"] = "gts:ReplyAuthorization",
|
|
["AnnounceAuthorization"] = "gts:AnnounceAuthorization",
|
|
["likeAuthorization"] = new JsonObject { ["@id"] = "gts:likeAuthorization", ["@type"] = "@id" },
|
|
["replyAuthorization"] = new JsonObject { ["@id"] = "gts:replyAuthorization", ["@type"] = "@id" },
|
|
["announceAuthorization"] = new JsonObject { ["@id"] = "gts:announceAuthorization", ["@type"] = "@id" },
|
|
["litepub"] = "http://litepub.social/ns#",
|
|
["EmojiReact"] = "litepub:EmojiReact",
|
|
["focalPoint"] = new JsonObject { ["@container"] = "@list", ["@id"] = "toot:focalPoint" },
|
|
["schema"] = "http://schema.org#",
|
|
["PropertyValue"] = "schema:PropertyValue",
|
|
["value"] = "schema:value",
|
|
["webfinger"] = "https://purl.archive.org/socialweb/webfinger#webfinger",
|
|
["lemmy"] = "https://join-lemmy.org/ns#",
|
|
["postingRestrictedToMods"] = "lemmy:postingRestrictedToMods"
|
|
});
|
|
|
|
public static string Html(string markdown) =>
|
|
string.IsNullOrEmpty(markdown) ? string.Empty : Markdown.ToHtml(markdown, Pipeline).Trim();
|
|
|
|
public static string Timestamp(DateTime value) =>
|
|
DateTime.SpecifyKind(value, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture);
|
|
|
|
public static string Day(DateTime value) =>
|
|
DateTime.SpecifyKind(value, DateTimeKind.Utc).Date.ToString("yyyy-MM-ddT00:00:00Z", CultureInfo.InvariantCulture);
|
|
|
|
public static string TagUrl(string baseAddress, string tag) => $"{baseAddress}/tags/{Uri.EscapeDataString(tag)}";
|
|
|
|
public static JsonObject Actor(LocalActor actor)
|
|
{
|
|
var document = new JsonObject
|
|
{
|
|
["@context"] = Context(),
|
|
["id"] = actor.Uri,
|
|
["type"] = actor.Kind switch
|
|
{
|
|
LocalActorKind.Group => "Group",
|
|
LocalActorKind.Application => "Application",
|
|
_ when actor.IsBot => "Service",
|
|
_ => "Person"
|
|
},
|
|
["preferredUsername"] = actor.UserName,
|
|
["name"] = actor.Name,
|
|
["summary"] = Html(actor.Summary),
|
|
["url"] = actor.Kind == LocalActorKind.Application ? actor.Uri : actor.HtmlUrl,
|
|
["inbox"] = actor.Inbox,
|
|
["outbox"] = actor.Outbox,
|
|
["followers"] = actor.Followers,
|
|
["following"] = actor.Following,
|
|
["featured"] = actor.Featured,
|
|
["featuredTags"] = actor.FeaturedTags,
|
|
["published"] = Day(actor.Published),
|
|
["manuallyApprovesFollowers"] = actor.ManuallyApprovesFollowers,
|
|
["discoverable"] = actor.Discoverable,
|
|
["indexable"] = actor.Indexable,
|
|
["webfinger"] = actor.Handle,
|
|
["endpoints"] = new JsonObject { ["sharedInbox"] = actor.SharedInbox },
|
|
["publicKey"] = new JsonObject
|
|
{
|
|
["id"] = actor.KeyId,
|
|
["owner"] = actor.Uri,
|
|
["publicKeyPem"] = Keys.ToSubjectPublicKeyInfoPem(actor.PublicKeyPem)
|
|
},
|
|
["attachment"] = new JsonArray(actor.Fields.Select(field => (JsonNode)new JsonObject
|
|
{
|
|
["type"] = "PropertyValue",
|
|
["name"] = field.Key,
|
|
["value"] = FieldValue(field.Value)
|
|
}).ToArray())
|
|
};
|
|
if (actor is { Kind: LocalActorKind.Group, IsCircle: false })
|
|
{
|
|
document["attributedTo"] = actor.Wardens;
|
|
document["postingRestrictedToMods"] = actor.PostingRestrictedToModerators;
|
|
}
|
|
if (!string.IsNullOrEmpty(actor.PictureURL))
|
|
document["icon"] = new JsonObject { ["type"] = "Image", ["url"] = actor.PictureURL };
|
|
if (!string.IsNullOrEmpty(actor.ThumbnailURL))
|
|
document["image"] = new JsonObject { ["type"] = "Image", ["url"] = actor.ThumbnailURL };
|
|
return document;
|
|
}
|
|
|
|
public static JsonObject Note(PostEntity post, LocalActor author, LocalActor group, string inReplyTo)
|
|
{
|
|
var mentions = post.Mentions.Select(m => (JsonNode)m.ActorURI).ToArray();
|
|
var (to, cc) = post.Visibility switch
|
|
{
|
|
PostVisibility.Circle when group != default => (new JsonArray(group.Uri, group.Flock), new JsonArray(mentions)),
|
|
PostVisibility.Unlisted => (new JsonArray(author.Followers), new JsonArray(mentions.Prepend(Public).ToArray())),
|
|
// Pleroma and Akkoma call a post private only if a `to` contains "/followers" or its cc is not empty, and ours
|
|
// are /groupies: with an empty cc every followers-only post would be a DM there. Naming them again says nothing new.
|
|
PostVisibility.FollowersOnly => (new JsonArray(author.Followers), new JsonArray(mentions.Append(author.Followers).ToArray())),
|
|
PostVisibility.Direct => (new JsonArray(mentions), new JsonArray()),
|
|
_ => (new JsonArray(Public), new JsonArray(mentions.Prepend(author.Followers).ToArray()))
|
|
};
|
|
if (group is { IsCircle: false })
|
|
cc.Add(group.Uri);
|
|
// a public reply names the author it answers in cc even when the @name was deleted from its text, as Pleroma does:
|
|
// GoToSocial keeps only what is addressed to someone there, and the reply already says whom it answers
|
|
if (post.Visibility is PostVisibility.Public or PostVisibility.Unlisted && !string.IsNullOrEmpty(post.InReplyToActorURI)
|
|
&& !mentions.Any(m => m.GetValue<string>() == post.InReplyToActorURI))
|
|
cc.Add(post.InReplyToActorURI);
|
|
|
|
var note = NoteBody(post, author, to, cc, inReplyTo);
|
|
if (group != default)
|
|
note["audience"] = group.Uri;
|
|
else if (!string.IsNullOrEmpty(post.AudienceURI))
|
|
note["audience"] = post.AudienceURI;
|
|
if (group is { IsCircle: false } && string.IsNullOrEmpty(inReplyTo))
|
|
{
|
|
note["type"] = "Page";
|
|
note["name"] = post.Title ?? Headline(post);
|
|
}
|
|
return note;
|
|
}
|
|
|
|
public static JsonObject DirectNote(PostEntity post, LocalActor author, IReadOnlyList<(string Uri, string Handle)> recipients,
|
|
string context)
|
|
{
|
|
var note = NoteBody(post, author, new JsonArray(recipients.Select(r => (JsonNode)r.Uri).ToArray()), new JsonArray(), post.InReplyToURI);
|
|
var named = post.Mentions.Select(m => m.ActorURI).ToHashSet();
|
|
var tags = note["tag"]!.AsArray();
|
|
foreach (var recipient in recipients.Where(r => !named.Contains(r.Uri)))
|
|
tags.Add(new JsonObject { ["type"] = "Mention", ["href"] = recipient.Uri, ["name"] = "@" + recipient.Handle });
|
|
var unmentioned = recipients.Where(r => !named.Contains(r.Uri)).ToList();
|
|
if (unmentioned.Count > 0)
|
|
note["content"] = "<p>" + string.Join(" ", unmentioned.Select(r =>
|
|
$"<span class=\"h-card\" translate=\"no\"><a href=\"{WebUtility.HtmlEncode(r.Uri)}\" class=\"u-url mention\">@<span>{WebUtility.HtmlEncode(r.Handle.Split('@')[0])}</span></a></span>"))
|
|
+ "</p>" + note["content"]!.GetValue<string>();
|
|
if (!string.IsNullOrEmpty(context))
|
|
note["context"] = context;
|
|
return note;
|
|
}
|
|
|
|
public static JsonObject UpdateOf(PostEntity post, LocalActor author, LocalActor group, string reason)
|
|
{
|
|
var note = post.Visibility == PostVisibility.Direct
|
|
? DirectNote(post, author, Array.Empty<(string, string)>(), post.ContextURI)
|
|
: Note(post, author, group, post.InReplyToURI);
|
|
note["to"] = new JsonArray(post.To.Select(t => (JsonNode)t).ToArray());
|
|
note["cc"] = new JsonArray(post.Cc.Select(c => (JsonNode)c).ToArray());
|
|
return new JsonObject
|
|
{
|
|
["@context"] = Context(),
|
|
["id"] = author.ActivityUri($"update-{post.ID}-{reason}"),
|
|
["type"] = "Update",
|
|
["actor"] = author.Uri,
|
|
["to"] = note["to"]!.DeepClone(),
|
|
["cc"] = note["cc"]!.DeepClone(),
|
|
["object"] = note
|
|
};
|
|
}
|
|
|
|
public static string QuotableBy(PostEntity post) =>
|
|
post.Visibility is PostVisibility.Public or PostVisibility.Unlisted ? post.LocalQuotePolicy ?? Models.User.QuotePolicies.Nobody : Models.User.QuotePolicies.Nobody;
|
|
|
|
static void AddQuote(JsonObject note, PostEntity post, ref string content)
|
|
{
|
|
if (string.IsNullOrEmpty(post.QuoteURI))
|
|
return;
|
|
var link = WebUtility.HtmlEncode(post.QuoteURI);
|
|
content += $"<p class=\"quote-inline\">RE: <a href=\"{link}\">{link}</a></p>";
|
|
if (post.QuoteByConsent)
|
|
note["quote"] = post.QuoteURI;
|
|
if (!string.IsNullOrEmpty(post.QuoteAuthorizationURI))
|
|
note["quoteAuthorization"] = post.QuoteAuthorizationURI;
|
|
note["_misskey_quote"] = post.QuoteURI;
|
|
note["quoteUri"] = post.QuoteURI;
|
|
note["quoteUrl"] = post.QuoteURI;
|
|
(note["tag"] as JsonArray)?.Add(new JsonObject
|
|
{
|
|
["type"] = "Link",
|
|
["mediaType"] = "application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"",
|
|
["href"] = post.QuoteURI,
|
|
["name"] = $"RE: {post.QuoteURI}"
|
|
});
|
|
}
|
|
|
|
static void AddPoll(JsonObject note, PostPoll poll)
|
|
{
|
|
note["type"] = "Question";
|
|
note[poll.Multiple ? "anyOf" : "oneOf"] = new JsonArray(poll.Options.Select(option => (JsonNode)new JsonObject
|
|
{
|
|
["type"] = "Note",
|
|
["name"] = option.Title,
|
|
["replies"] = new JsonObject { ["type"] = "Collection", ["totalItems"] = option.Votes }
|
|
}).ToArray());
|
|
if (poll.ExpiresAt is { } ends)
|
|
note["endTime"] = Timestamp(ends);
|
|
if (poll.ClosedAt is { } closed)
|
|
note["closed"] = Timestamp(closed);
|
|
if (poll.VotersCount is { } voters)
|
|
note["votersCount"] = voters;
|
|
}
|
|
|
|
static string Headline(PostEntity post)
|
|
{
|
|
var text = System.Text.RegularExpressions.Regex.Replace(post.ContentHtml ?? post.Text ?? string.Empty, "<[^>]+>", " ");
|
|
text = WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(text, "\\s+", " ")).Trim();
|
|
return text.Length <= 100 ? text : text[..97] + "...";
|
|
}
|
|
|
|
static JsonObject NoteBody(PostEntity post, LocalActor author, JsonArray to, JsonArray cc, string inReplyTo)
|
|
{
|
|
var content = post.ContentHtml ?? Html(post.Text);
|
|
if (!string.IsNullOrEmpty(post.Title))
|
|
content = $"<p><strong>{WebUtility.HtmlEncode(post.Title)}</strong></p>{content}";
|
|
|
|
var note = new JsonObject
|
|
{
|
|
["id"] = author.PostUri(post.ID),
|
|
["type"] = "Note",
|
|
["attributedTo"] = author.Uri,
|
|
["content"] = content,
|
|
["published"] = Timestamp(post.CreationDate),
|
|
["url"] = author.PostHtmlUrl(post.ID),
|
|
["to"] = to,
|
|
["cc"] = cc,
|
|
["sensitive"] = post.HasContentWarning,
|
|
["tag"] = new JsonArray(post.Mentions
|
|
.Select(m => (JsonNode)new JsonObject { ["type"] = "Mention", ["href"] = m.ActorURI, ["name"] = MentionName(m) })
|
|
.Concat(post.Tags.Select(t => (JsonNode)new JsonObject
|
|
{
|
|
["type"] = "Hashtag",
|
|
["href"] = TagUrl(author.BaseAddress, t),
|
|
["name"] = "#" + t
|
|
}))
|
|
.ToArray())
|
|
};
|
|
AddQuote(note, post, ref content);
|
|
if (!post.IsFederatedCopy)
|
|
note["interactionPolicy"] = new JsonObject
|
|
{
|
|
["canQuote"] = new JsonObject
|
|
{
|
|
["automaticApproval"] = new JsonArray((JsonNode)(QuotableBy(post) switch
|
|
{
|
|
Models.User.QuotePolicies.Public => Objects.Addressing.Public,
|
|
Models.User.QuotePolicies.Followers => author.Followers,
|
|
_ => author.Uri
|
|
}))
|
|
}
|
|
};
|
|
note["content"] = content;
|
|
if (!string.IsNullOrEmpty(post.Language))
|
|
note["contentMap"] = new JsonObject { [post.Language] = content };
|
|
if (post.Poll is { } poll)
|
|
AddPoll(note, poll);
|
|
var attachments = post.Media.Where(m => !string.IsNullOrEmpty(m.URL)).Select(m =>
|
|
{
|
|
var attachment = new JsonObject
|
|
{
|
|
["type"] = "Document",
|
|
["mediaType"] = m.ContentType,
|
|
["url"] = m.URL,
|
|
["name"] = m.Description
|
|
};
|
|
if (!string.IsNullOrEmpty(m.Blurhash))
|
|
attachment["blurhash"] = m.Blurhash;
|
|
if (m.Focus is { Length: 2 })
|
|
attachment["focalPoint"] = new JsonArray(m.Focus[0], m.Focus[1]);
|
|
if (m.Width.HasValue && m.Height.HasValue)
|
|
{
|
|
attachment["width"] = m.Width;
|
|
attachment["height"] = m.Height;
|
|
}
|
|
return (JsonNode)attachment;
|
|
}).ToArray();
|
|
if (attachments.Length > 0)
|
|
note["attachment"] = new JsonArray(attachments);
|
|
if (!string.IsNullOrEmpty(post.Title))
|
|
note["name"] = post.Title;
|
|
var summary = post.SpoilerText ?? (post.HasContentWarning ? post.Title ?? ContentWarning : default);
|
|
if (!string.IsNullOrEmpty(summary))
|
|
{
|
|
note["summary"] = summary;
|
|
note["sensitive"] = true;
|
|
}
|
|
if (!string.IsNullOrEmpty(inReplyTo))
|
|
note["inReplyTo"] = inReplyTo;
|
|
// the parent's author let this reply in (GoToSocial's interaction policies)
|
|
if (!string.IsNullOrEmpty(inReplyTo) && !string.IsNullOrEmpty(post.ApprovalURI))
|
|
note["replyAuthorization"] = post.ApprovalURI;
|
|
if (post.EditedAt.HasValue)
|
|
note["updated"] = Timestamp(post.EditedAt.Value);
|
|
return note;
|
|
}
|
|
|
|
static string MentionName(PostMention mention)
|
|
{
|
|
var handle = mention.Handle?.TrimStart('@');
|
|
if (string.IsNullOrEmpty(handle))
|
|
return "@" + mention.ActorURI;
|
|
return "@" + (handle.Contains('@') ? handle : $"{handle}@{new Uri(mention.ActorURI).Authority}");
|
|
}
|
|
|
|
public static string FieldValue(string value)
|
|
{
|
|
var encoded = WebUtility.HtmlEncode(value ?? string.Empty);
|
|
return Uri.TryCreate(value, UriKind.Absolute, out var link) && link.Scheme is "https" or "http"
|
|
? $"<a href=\"{encoded}\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\" translate=\"no\">{encoded}</a>"
|
|
: encoded;
|
|
}
|
|
|
|
public static JsonObject Create(LocalActor actor, JsonObject note, string activityId) => new()
|
|
{
|
|
["@context"] = Context(),
|
|
["id"] = actor.ActivityUri(activityId),
|
|
["type"] = "Create",
|
|
["actor"] = actor.Uri,
|
|
["published"] = note["published"]?.DeepClone(),
|
|
["to"] = note["to"]?.DeepClone(),
|
|
["cc"] = note["cc"]?.DeepClone(),
|
|
["object"] = note
|
|
};
|
|
|
|
public static JsonObject Announce(LocalActor group, string objectUri, string activityId, DateTime? published = default) => new()
|
|
{
|
|
["@context"] = ActivityStreams,
|
|
["id"] = group.ActivityUri(activityId),
|
|
["type"] = "Announce",
|
|
["actor"] = group.Uri,
|
|
["published"] = Timestamp(published ?? DateTime.UtcNow),
|
|
["to"] = new JsonArray(Public),
|
|
["cc"] = new JsonArray(group.Followers),
|
|
["object"] = objectUri
|
|
};
|
|
|
|
public static JsonObject Delete(LocalActor actor, string objectUri, string activityId, JsonArray to, JsonArray cc) => new()
|
|
{
|
|
["@context"] = ActivityStreams,
|
|
["id"] = actor.ActivityUri(activityId),
|
|
["type"] = "Delete",
|
|
["actor"] = actor.Uri,
|
|
["to"] = to,
|
|
["cc"] = cc,
|
|
["object"] = new JsonObject { ["id"] = objectUri, ["type"] = "Tombstone" }
|
|
};
|
|
|
|
public static JsonObject Accept(LocalActor actor, JsonNode follow, string activityId) => new()
|
|
{
|
|
["@context"] = ActivityStreams,
|
|
["id"] = actor.ActivityUri(activityId),
|
|
["type"] = "Accept",
|
|
["actor"] = actor.Uri,
|
|
["object"] = follow.DeepClone()
|
|
};
|
|
|
|
public static JsonObject OrderedCollection(string id, int totalItems, IEnumerable<JsonNode> items, string first = default)
|
|
{
|
|
var collection = new JsonObject
|
|
{
|
|
["@context"] = ActivityStreams,
|
|
["id"] = id,
|
|
["type"] = "OrderedCollection",
|
|
["totalItems"] = totalItems
|
|
};
|
|
if (first != default)
|
|
collection["first"] = first;
|
|
if (items != default)
|
|
collection["orderedItems"] = new JsonArray(items.ToArray());
|
|
return collection;
|
|
}
|
|
|
|
public static JsonObject OrderedCollectionPage(string id, string partOf, IEnumerable<JsonNode> items, string next, string prev)
|
|
{
|
|
var page = new JsonObject
|
|
{
|
|
["@context"] = Context(),
|
|
["id"] = id,
|
|
["type"] = "OrderedCollectionPage",
|
|
["partOf"] = partOf,
|
|
["orderedItems"] = new JsonArray(items.ToArray())
|
|
};
|
|
if (next != default)
|
|
page["next"] = next;
|
|
if (prev != default)
|
|
page["prev"] = prev;
|
|
return page;
|
|
}
|
|
}
|
|
}
|