Files
SocialPub/PrivaPub/Federation/Objects/NoteParser.cs
T
thepraandClaude Opus 5.5 31d1d21d1e Pixelfed places: a remote post's own place, shown and never federated again
A Note's `location` that is a Place with coordinates (Pixelfed sends
them as strings) is kept in the new Post.Place and shown as
Status.privapub.place {name, latitude, longitude, country}; an edit
replaces it. An Event's location stays its own `places`, and nothing
renders a place back out. Closes the INTEROP P2 Pixelfed location gap
and the ROADMAP long-tail item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
2026-10-04 10:07:06 +02:00

226 lines
9.6 KiB
C#

using PrivaPub.Models.Post;
using System.Globalization;
using System.Text;
using System.Text.Json.Nodes;
using static PrivaPub.Federation.Objects.ActivityJson;
namespace PrivaPub.Federation.Objects
{
public sealed class NoteDocument
{
public string Id { get; init; }
public string Type { get; init; }
public JsonObject Raw { get; init; }
public string AttributedTo { get; init; }
public string ContentHtml { get; init; }
public string Title { get; init; }
public string SpoilerText { get; init; }
public string Excerpt { get; init; }
public bool Sensitive { get; init; }
public string Language { get; init; }
public string Url { get; init; }
public string InReplyTo { get; init; }
public string Context { get; init; }
public string Audience { get; init; }
public string QuoteUri { get; init; }
public string QuoteAuthorization { get; init; }
public bool QuotesByConsent { get; init; }//FEP-044f `quote`, as opposed to the legacy keys that ask nobody
public bool QuoteDeleted { get; init; }
public DateTime Published { get; init; }
public DateTime? Updated { get; init; }
public IReadOnlyList<string> To { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> Cc { get; init; } = Array.Empty<string>();
public IReadOnlyList<PostMention> Mentions { get; init; } = Array.Empty<PostMention>();
public IReadOnlyList<string> Tags { get; init; } = Array.Empty<string>();
public IReadOnlyList<PostMedia> Attachments { get; init; } = Array.Empty<PostMedia>();
public PostSource Source { get; init; }
public PostPoll Poll { get; init; }
public InteractionRule QuotePolicy { get; init; }
public IReadOnlyList<CustomEmoji> Emojis { get; init; } = Array.Empty<CustomEmoji>();
public string CoverURL { get; init; }
public PostLink Link { get; init; }
public VideoDetails Video { get; init; }
public AudioDetails Audio { get; init; }
public EventDetails Event { get; init; }
public PostPlace Place { get; init; }
}
public static class NoteParser
{
static readonly string[] NoteTypes = { "Note", "Article", "Page", "Question", "Event", "Video", "Audio", "Image", "ChatMessage" };
const int MaxAttachments = 16;
const int MaxTags = 64;
public static NoteDocument Parse(JsonNode node)
{
if (node is not JsonObject note)
return default;
var type = Value(note, "type");
var id = Id(note);
if (!NoteTypes.Contains(type) || Origin.Of(id) == default)
return default;
var (contentHtml, language) = Content(note);
var summary = Value(note, "summary");
var excerpt = ObjectShapes.Text(summary, 1000)
?? (note["preview"] is JsonObject preview && Value(preview, "type") == "Note" ? ObjectShapes.Text(Value(preview, "content"), 1000) : default);
var sensitive = note["sensitive"] is JsonValue flag && flag.TryGetValue<bool>(out var marked) && marked;
var summaryWarns = type is "Note" or "Question" || sensitive;
return new NoteDocument
{
Id = id,
Type = type,
Raw = note,
AttributedTo = Attribution(note["attributedTo"]),
ContentHtml = ContentSanitizer.Html(contentHtml),
Title = ObjectShapes.Text(Value(note, "name"), 500),
SpoilerText = summaryWarns ? Plain(summary, 500) : default,
Excerpt = summaryWarns ? default : excerpt,
Sensitive = sensitive || summaryWarns && !string.IsNullOrWhiteSpace(summary),
Language = language,
Url = UrlOf(note["url"]) ?? id,
InReplyTo = Id(note["inReplyTo"]),
Context = Id(note["context"]) ?? Value(note, "conversation"),
Audience = Id(note["audience"]),
QuoteUri = Id(note["quote"]) ?? Value(note, "quoteUrl") ?? Value(note, "quoteUri") ?? Value(note, "_misskey_quote") ?? QuoteLink(note),
QuoteAuthorization = Id(note["quoteAuthorization"]),
QuotesByConsent = note.ContainsKey("quote"),
QuoteDeleted = note["quote"] is JsonObject quoted && Value(quoted, "type") == "Tombstone",
Published = Time(Value(note, "published")) ?? DateTime.UtcNow,
Updated = Time(Value(note, "updated")),
To = List(note["to"]),
Cc = List(note["cc"]),
Mentions = Tags(note, "Mention").Select(t => new PostMention { ActorURI = Value(t, "href"), Handle = Value(t, "name") })
.Where(m => Origin.Of(m.ActorURI) != default)
.DistinctBy(m => m.ActorURI)
.Take(MaxTags)
.ToList(),
Tags = Tags(note, "Hashtag").Select(t => Value(t, "name")?.TrimStart('#').Normalize(NormalizationForm.FormKC).ToLowerInvariant())
.Where(t => !string.IsNullOrEmpty(t))
.Distinct()
.Take(MaxTags)
.ToList(),
Attachments = Attachments(note),
Source = ObjectShapes.Source(note),
Poll = ObjectShapes.Poll(note),
QuotePolicy = ObjectShapes.QuotePolicy(note),
Emojis = ObjectShapes.Emojis(note["tag"]),
CoverURL = ObjectShapes.Cover(note),
Link = ObjectShapes.Link(note),
Video = ObjectShapes.Video(note),
Audio = ObjectShapes.Audio(note),
Event = ObjectShapes.Event(note),
Place = ObjectShapes.NotePlace(note)
};
}
static (string Html, string Language) Content(JsonObject note)
{
var content = Value(note, "content");
string language = default;
if (note["contentMap"] is JsonObject map && map.Count > 0)
{
var first = map.First();
language = first.Key;
if (string.IsNullOrEmpty(content) && first.Value is JsonValue value && value.TryGetValue<string>(out var mapped))
content = mapped;
}
if (string.IsNullOrEmpty(content) && Value(note, "_misskey_content") is { } misskey)
content = System.Net.WebUtility.HtmlEncode(misskey).Replace("\n", "<br>");
if (!string.IsNullOrEmpty(content) && Value(note, "mediaType")?.StartsWith("text/markdown", StringComparison.OrdinalIgnoreCase) == true)
content = Rendering.ActivityPubRenderer.Html(content);
return (content, language ?? ObjectShapes.Language(note));
}
static string Attribution(JsonNode node) => node switch
{
JsonArray array => array.OfType<JsonObject>().Where(o => Value(o, "type") is "Person" or "Service" or "Application" or "Organization").Select(Id)
.Concat(array.OfType<JsonValue>().Select(Id))
.Concat(array.OfType<JsonObject>().Where(o => Value(o, "type") == "Group").Select(Id))
.FirstOrDefault(id => id != default),
_ => Id(node)
};
static IEnumerable<JsonObject> Tags(JsonObject note, string type) =>
(note["tag"] switch
{
JsonArray array => array.OfType<JsonObject>(),
JsonObject single => new[] { single },
_ => Enumerable.Empty<JsonObject>()
}).Where(t => Value(t, "type") == type);
static List<PostMedia> Attachments(JsonObject note) =>
ObjectShapes.Objects(note["attachment"])
.Where(a => Value(a, "type") is "Document" or "Image" or "Video" or "Audio")
.Select(Attachment)
.Where(m => Origin.Of(m.RemoteURL) != default)
.Take(MaxAttachments)
.ToList();
static PostMedia Attachment(JsonObject attachment)
{
var (url, linkMediaType) = ObjectShapes.MediaLink(attachment["url"] ?? attachment["href"]);
return new PostMedia
{
RemoteURL = url,
ContentType = ObjectShapes.InferMediaType(Value(attachment, "mediaType") ?? linkMediaType, url, Value(attachment, "type")),
RemotePreviewURL = ObjectShapes.Image(attachment["icon"]),
DurationSeconds = ObjectShapes.Seconds(attachment["duration"]),
Sensitive = attachment["sensitive"] is JsonValue flag && flag.TryGetValue<bool>(out var marked) ? marked : default(bool?),
Description = Plain(Value(attachment, "name") ?? Value(attachment, "summary") ?? FirstOf(attachment["nameMap"]) ?? FirstOf(attachment["summaryMap"]), 1500),
Blurhash = Value(attachment, "blurhash"),
Width = Int(attachment["width"]),
Height = Int(attachment["height"]),
Focus = attachment["focalPoint"] is JsonArray focus && focus.Count == 2
? focus.Select(f => f is JsonValue v && v.TryGetValue<double>(out var d) ? (float)Math.Clamp(d, -1, 1) : 0f).ToArray()
: default
};
}
static string QuoteLink(JsonObject note) =>
Tags(note, "Link")
.Where(t => Value(t, "mediaType") is { } type && (type.StartsWith("application/activity+json") || type.StartsWith("application/ld+json")))
.Select(t => Value(t, "href"))
.FirstOrDefault(href => Origin.Of(href) != default);
static string FirstOf(JsonNode map) =>
map is JsonObject languages ? languages.Select(l => l.Value is JsonValue v && v.TryGetValue<string>(out var text) ? text : default).FirstOrDefault(t => t != default) : default;
static string UrlOf(JsonNode node) => node switch
{
JsonValue value when value.TryGetValue<string>(out var text) => text,
JsonObject obj => Value(obj, "href") ?? Value(obj, "id"),
JsonArray array => array.Select(item => item is JsonObject link && Value(link, "mediaType") is { } mediaType && !mediaType.StartsWith("text/html")
? default
: UrlOf(item))
.FirstOrDefault(url => url != default) ?? array.Select(UrlOf).FirstOrDefault(url => url != default),
_ => default
};
static IReadOnlyList<string> List(JsonNode node) => node switch
{
JsonArray array => array.Select(Id).Where(id => id != default).Distinct(StringComparer.Ordinal).ToList(),
JsonNode single when Id(single) is { } id => new[] { id },
_ => Array.Empty<string>()
};
static DateTime? Time(string value) =>
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed)
? parsed.UtcDateTime
: (DateTime?)null;
static int? Int(JsonNode node) =>
node is JsonValue value && value.TryGetValue<int>(out var number) && number is > 0 and < 100_000 ? number : (int?)null;
static string Plain(string value, int max)
{
if (string.IsNullOrWhiteSpace(value))
return default;
value = value.Trim();
return value.Length <= max ? value : value[..max];
}
}
}