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
398 lines
16 KiB
C#
398 lines
16 KiB
C#
using PrivaPub.Models.Post;
|
|
|
|
using System.Globalization;
|
|
using System.Net;
|
|
using System.Text.Json.Nodes;
|
|
using System.Text.RegularExpressions;
|
|
using System.Xml;
|
|
|
|
using static PrivaPub.Federation.Objects.ActivityJson;
|
|
|
|
namespace PrivaPub.Federation.Objects
|
|
{
|
|
public static partial class ObjectShapes
|
|
{
|
|
const int MaxVariants = 20;
|
|
const int MaxCaptions = 20;
|
|
const int MaxPlaces = 4;
|
|
const int CoverMaxWidth = 1280;
|
|
const int MaxEmojis = 64;
|
|
const int MaxPollOptions = 20;
|
|
const int MaxAudience = 32;
|
|
|
|
static readonly Dictionary<string, string> MediaTypesByExtension = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
[".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png", [".gif"] = "image/gif", [".webp"] = "image/webp",
|
|
[".avif"] = "image/avif", [".heic"] = "image/heic", [".svg"] = "image/svg+xml",
|
|
[".mp4"] = "video/mp4", [".m4v"] = "video/mp4", [".webm"] = "video/webm", [".mov"] = "video/quicktime", [".mkv"] = "video/x-matroska",
|
|
[".mp3"] = "audio/mpeg", [".ogg"] = "audio/ogg", [".oga"] = "audio/ogg", [".opus"] = "audio/opus", [".m4a"] = "audio/mp4",
|
|
[".flac"] = "audio/flac", [".wav"] = "audio/wav", [".m3u8"] = "application/x-mpegURL"
|
|
};
|
|
|
|
public static IEnumerable<JsonObject> Objects(JsonNode node) => node switch
|
|
{
|
|
JsonArray array => array.OfType<JsonObject>(),
|
|
JsonObject single => new[] { single },
|
|
_ => Enumerable.Empty<JsonObject>()
|
|
};
|
|
|
|
public static (string Url, string MediaType) MediaLink(JsonNode node)
|
|
{
|
|
switch (node)
|
|
{
|
|
case JsonValue value when value.TryGetValue<string>(out var text):
|
|
return (text, default);
|
|
case JsonObject link when Value(link, "href") is { } href:
|
|
return (href, Value(link, "mediaType"));
|
|
case JsonObject wrapper when wrapper["url"] is { } inner:
|
|
var (url, mediaType) = MediaLink(inner);
|
|
return (url, mediaType ?? Value(wrapper, "mediaType"));
|
|
case JsonArray array:
|
|
var links = array.Select(MediaLink).Where(l => l.Url != default).ToList();
|
|
return links.FirstOrDefault(l => l.MediaType?.StartsWith("text/html", StringComparison.OrdinalIgnoreCase) != true);
|
|
default:
|
|
return default;
|
|
}
|
|
}
|
|
|
|
public static string InferMediaType(string declared, string url, string objectType)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(declared) && declared.Contains('/'))
|
|
return declared.Trim();
|
|
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && MediaTypesByExtension.TryGetValue(Path.GetExtension(uri.AbsolutePath), out var byExtension))
|
|
return byExtension;
|
|
return objectType switch
|
|
{
|
|
"Image" => "image/*",
|
|
"Video" => "video/*",
|
|
"Audio" => "audio/*",
|
|
_ => default
|
|
};
|
|
}
|
|
|
|
public static string Image(JsonNode node)
|
|
{
|
|
var candidates = node switch
|
|
{
|
|
JsonValue value when value.TryGetValue<string>(out var text) => new[] { (Url: text, Width: (int?)default) },
|
|
JsonArray array => array.Select(item => (Url: MediaLink(item).Url, Width: Int(item as JsonObject, "width"))).ToArray(),
|
|
JsonObject single => new[] { (Url: MediaLink(single).Url, Width: Int(single, "width")) },
|
|
_ => Array.Empty<(string Url, int? Width)>()
|
|
};
|
|
var usable = candidates.Where(c => Origin.Of(c.Url) != default).ToList();
|
|
return usable.Where(c => c.Width <= CoverMaxWidth).OrderByDescending(c => c.Width).Select(c => c.Url).FirstOrDefault()
|
|
?? usable.Select(c => c.Url).FirstOrDefault();
|
|
}
|
|
|
|
public static List<CustomEmoji> Emojis(JsonNode tags) =>
|
|
Objects(tags)
|
|
.Where(t => Value(t, "type") == "Emoji")
|
|
.Select(t => new CustomEmoji
|
|
{
|
|
Shortcode = Value(t, "name")?.Trim(':'),
|
|
URL = MediaLink(t["icon"]).Url,
|
|
Updated = Moment(Value(t, "updated"))
|
|
})
|
|
.Where(e => !string.IsNullOrEmpty(e.Shortcode) && e.Shortcode.Length <= 100 && Shortcode().IsMatch(e.Shortcode) && Origin.Of(e.URL) != default)
|
|
.DistinctBy(e => e.Shortcode)
|
|
.Take(MaxEmojis)
|
|
.ToList();
|
|
|
|
public static InteractionRule QuotePolicy(JsonObject note)
|
|
{
|
|
if (note["interactionPolicy"] is not JsonObject policy || policy["canQuote"] is not JsonObject canQuote)
|
|
return default;
|
|
static List<string> Who(JsonNode node) => node switch
|
|
{
|
|
JsonArray array => array.Select(Id).Where(id => id != default).Take(MaxAudience).ToList(),
|
|
JsonNode single when Id(single) is { } id => new List<string> { id },
|
|
_ => new List<string>()
|
|
};
|
|
return new InteractionRule
|
|
{
|
|
Automatic = Who(canQuote["automaticApproval"] ?? canQuote["always"]),
|
|
Manual = Who(canQuote["manualApproval"] ?? canQuote["approvalRequired"])
|
|
};
|
|
}
|
|
|
|
public static PostPoll Poll(JsonObject note)
|
|
{
|
|
if (Value(note, "type") != "Question")
|
|
return default;
|
|
var multiple = note["anyOf"] is JsonArray;
|
|
var options = Objects(note[multiple ? "anyOf" : "oneOf"])
|
|
.Select(o => new PollOption
|
|
{
|
|
Title = Text(Value(o, "name"), 200),
|
|
Votes = o["replies"] is JsonObject replies && Int(replies, "totalItems") is { } total ? total : Int(o, "_misskey_votes") ?? 0
|
|
})
|
|
.Where(o => o.Title != default)
|
|
.Take(MaxPollOptions)
|
|
.ToList();
|
|
if (options.Count < 2)
|
|
return default;
|
|
var closedAt = note["closed"] switch
|
|
{
|
|
JsonValue value when value.TryGetValue<bool>(out var closed) => closed ? Moment(Value(note, "endTime")) ?? DateTime.UtcNow : default(DateTime?),
|
|
JsonValue value when value.TryGetValue<string>(out var at) => Moment(at),
|
|
_ => default
|
|
};
|
|
var expiresAt = Moment(Value(note, "endTime"));
|
|
// Pleroma and Akkoma put an open poll's end in `closed`, often without `endTime`: it is closed once that has passed
|
|
if (closedAt > DateTime.UtcNow)
|
|
{
|
|
expiresAt ??= closedAt;
|
|
closedAt = default;
|
|
}
|
|
return new PostPoll
|
|
{
|
|
Options = options,
|
|
Multiple = multiple,
|
|
ExpiresAt = expiresAt,
|
|
ClosedAt = closedAt,
|
|
VotersCount = Int(note, "votersCount")
|
|
};
|
|
}
|
|
|
|
public static string Cover(JsonObject note) =>
|
|
Image(note["image"]) ?? Image(note["icon"]) ?? (note["preview"] is JsonObject preview && Value(preview, "type") == "Image" ? Image(preview) : default);
|
|
|
|
public static double? Seconds(JsonNode node)
|
|
{
|
|
switch (node)
|
|
{
|
|
case JsonValue value when value.TryGetValue<double>(out var number):
|
|
return number >= 0 && number < 1e7 ? number : default;
|
|
case JsonValue value when value.TryGetValue<string>(out var text) && !string.IsNullOrWhiteSpace(text):
|
|
try
|
|
{
|
|
var span = XmlConvert.ToTimeSpan(text.Trim());
|
|
return span >= TimeSpan.Zero ? span.TotalSeconds : default;
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds) && seconds >= 0 ? seconds : default;
|
|
}
|
|
default:
|
|
return default;
|
|
}
|
|
}
|
|
|
|
public static string Text(string html, int max)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(html))
|
|
return default;
|
|
var text = WebUtility.HtmlDecode(Tags().Replace(html, " "));
|
|
text = Spaces().Replace(text, " ").Trim();
|
|
if (text.Length == 0)
|
|
return default;
|
|
return text.Length <= max ? text : text[..max];
|
|
}
|
|
|
|
public static string Language(JsonObject note) =>
|
|
note["@context"] is JsonArray context
|
|
? context.OfType<JsonObject>().Select(entry => Value(entry, "@language")).FirstOrDefault(language => !string.IsNullOrWhiteSpace(language))
|
|
: default;
|
|
|
|
public static PostSource Source(JsonObject note) => note["source"] is JsonObject source && Value(source, "content") is { } content
|
|
? new PostSource { Content = content.Length <= 64 * 1024 ? content : content[..(64 * 1024)], MediaType = Value(source, "mediaType") }
|
|
: default;
|
|
|
|
public static PostLink Link(JsonObject note)
|
|
{
|
|
var attached = Objects(note["attachment"]).FirstOrDefault(a => Value(a, "type") == "Link" && IsWeb(Value(a, "href")));
|
|
if (attached != default)
|
|
{
|
|
var preview = attached["preview"] as JsonObject;
|
|
return new PostLink
|
|
{
|
|
Href = Value(attached, "href"),
|
|
MediaType = Value(attached, "mediaType"),
|
|
Title = Text(Value(preview, "name") ?? Value(attached, "name"), 300),
|
|
Description = Text(Value(preview, "summary") ?? Value(preview, "content"), 1000),
|
|
ImageURL = preview == default ? Image(note["image"]) : Image(preview["image"]) ?? Image(preview["icon"]),
|
|
FromPublisher = preview != default
|
|
};
|
|
}
|
|
if (note["source"] is JsonValue source && source.TryGetValue<string>(out var sourceUrl) && IsWeb(sourceUrl))
|
|
return new PostLink { Href = sourceUrl, ImageURL = Image(note["image"]) };
|
|
return default;
|
|
}
|
|
|
|
public static VideoDetails Video(JsonObject note)
|
|
{
|
|
if (Value(note, "type") != "Video")
|
|
return default;
|
|
var links = Objects(note["url"]).ToList();
|
|
var nested = links.Where(l => Value(l, "mediaType")?.Equals("application/x-mpegURL", StringComparison.OrdinalIgnoreCase) == true)
|
|
.SelectMany(l => Objects(l["tag"]))
|
|
.ToList();
|
|
return new VideoDetails
|
|
{
|
|
DurationSeconds = Seconds(note["duration"]),
|
|
Variants = Variants(links.Concat(nested).ToList(), "video/", "audio/"),
|
|
PlaylistURL = links.Where(l => Value(l, "mediaType")?.Equals("application/x-mpegURL", StringComparison.OrdinalIgnoreCase) == true)
|
|
.Select(l => Value(l, "href")).FirstOrDefault(IsWeb),
|
|
IsLive = Bool(note, "isLiveBroadcast") == true,
|
|
State = Int(note, "state"),
|
|
Views = Long(note, "views"),
|
|
CommentsPolicy = Int(note, "commentsPolicy"),
|
|
DownloadEnabled = Bool(note, "downloadEnabled"),
|
|
Licence = Named(note["licence"] ?? note["license"]),
|
|
Category = Named(note["category"]),
|
|
ChannelURI = Objects(note["attributedTo"]).Where(a => Value(a, "type") == "Group").Select(a => Id(a)).FirstOrDefault(),
|
|
EmbedURL = Value(note, "embedUrl") is { } embed && IsWeb(embed) ? embed : default,
|
|
ChaptersURL = Value(note, "hasParts") is { } chapters && IsWeb(chapters) ? chapters : default,
|
|
Captions = Objects(note["subtitleLanguage"])
|
|
.Select(c => new Caption
|
|
{
|
|
Language = Value(c, "identifier"),
|
|
Url = Objects(c["url"]).Where(u => Value(u, "mediaType") == "text/vtt").Select(u => Value(u, "href")).FirstOrDefault(IsWeb)
|
|
?? (Value(c, "url") is { } plain && IsWeb(plain) ? plain : default)
|
|
})
|
|
.Where(c => c.Url != default)
|
|
.Take(MaxCaptions)
|
|
.ToList()
|
|
};
|
|
}
|
|
|
|
public static AudioDetails Audio(JsonObject note)
|
|
{
|
|
if (Value(note, "type") != "Audio")
|
|
return default;
|
|
return new AudioDetails
|
|
{
|
|
DurationSeconds = Seconds(note["duration"]),
|
|
Variants = Variants(Objects(note["url"]).ToList(), "audio/"),
|
|
Album = Named(note["album"]),
|
|
Position = Int(note, "position"),
|
|
Licence = Named(note["license"] ?? note["licence"])
|
|
};
|
|
}
|
|
|
|
public static EventDetails Event(JsonObject note)
|
|
{
|
|
if (Value(note, "type") != "Event")
|
|
return default;
|
|
var start = Value(note, "startTime");
|
|
var places = Objects(note["location"]).Select(Place).Where(p => p != default).Take(MaxPlaces).ToList();
|
|
return new EventDetails
|
|
{
|
|
Start = Moment(start),
|
|
End = Moment(Value(note, "endTime")),
|
|
TimeZone = Value(note, "timezone"),
|
|
FloatingTime = start?.EndsWith("-00:00", StringComparison.Ordinal) == true,
|
|
Places = places,
|
|
OnlineURL = places.Select(p => p.Url).FirstOrDefault(u => u != default)
|
|
?? Objects(note["attachment"]).Where(a => Value(a, "type") == "Link" && Value(a, "name") == "Website").Select(a => Value(a, "href")).FirstOrDefault(IsWeb),
|
|
JoinMode = Value(note, "joinMode"),
|
|
ParticipantCount = Int(note, "participantCount"),
|
|
Capacity = Int(note, "maximumAttendeeCapacity"),
|
|
Status = Value(note, "ical:status") ?? Value(note, "eventStatus") ?? Value(note, "status"),
|
|
Category = Named(note["category"])
|
|
};
|
|
}
|
|
|
|
//a post's own place, as Pixelfed sends it: a Place with coordinates (numbers, or numbers as strings); an Event's
|
|
//places are its own (Event)
|
|
public static PostPlace NotePlace(JsonObject note)
|
|
{
|
|
if (Value(note, "type") == "Event")
|
|
return default;
|
|
var place = Objects(note["location"]).FirstOrDefault(l => Value(l, "type") == "Place");
|
|
var latitude = Coordinate(place, "latitude");
|
|
var longitude = Coordinate(place, "longitude");
|
|
if (latitude is not (>= -90 and <= 90) || longitude is not (>= -180 and <= 180))
|
|
return default;
|
|
return new PostPlace
|
|
{
|
|
Name = Text(Value(place, "name"), 200),
|
|
Latitude = Math.Round(latitude.Value, 4),
|
|
Longitude = Math.Round(longitude.Value, 4),
|
|
Country = Text(Value(place, "country"), 100)
|
|
};
|
|
}
|
|
|
|
static double? Coordinate(JsonObject node, string property) =>
|
|
Double(node, property) ?? (double.TryParse(Value(node, property), NumberStyles.Float, CultureInfo.InvariantCulture, out var number) && double.IsFinite(number)
|
|
? number
|
|
: (double?)null);
|
|
|
|
static EventPlace Place(JsonObject location)
|
|
{
|
|
var type = Value(location, "type");
|
|
if (type == "VirtualLocation")
|
|
return Value(location, "url") is { } url && IsWeb(url) ? new EventPlace { Name = Text(Value(location, "name"), 200), Url = url } : default;
|
|
var address = location["address"] switch
|
|
{
|
|
JsonValue value when value.TryGetValue<string>(out var text) => text,
|
|
JsonObject postal => string.Join(", ", new[] { "streetAddress", "postalCode", "addressLocality", "addressRegion", "addressCountry" }
|
|
.Select(field => Value(postal, field)).Where(part => !string.IsNullOrWhiteSpace(part))),
|
|
_ => default
|
|
};
|
|
var place = new EventPlace
|
|
{
|
|
Name = Text(Value(location, "name") ?? Value(location, "content"), 200),
|
|
Address = Text(address, 300),
|
|
Latitude = Double(location, "latitude"),
|
|
Longitude = Double(location, "longitude")
|
|
};
|
|
return place.Name == default && place.Address == default && place.Latitude == default ? default : place;
|
|
}
|
|
|
|
static List<MediaVariant> Variants(List<JsonObject> links, params string[] prefixes) =>
|
|
links.Where(l => Value(l, "mediaType") is { } type && prefixes.Any(p => type.StartsWith(p, StringComparison.OrdinalIgnoreCase)) && IsWeb(Value(l, "href")))
|
|
.Select(l => new MediaVariant
|
|
{
|
|
Url = Value(l, "href"),
|
|
MediaType = Value(l, "mediaType"),
|
|
Width = Int(l, "width"),
|
|
Height = Int(l, "height"),
|
|
Fps = Double(l, "fps"),
|
|
Size = Long(l, "size"),
|
|
Streams = Objects(l["attachment"])
|
|
.Where(p => Value(p, "name") == "ffprobe_codec_type")
|
|
.Select(p => Value(p, "value"))
|
|
.Where(v => v is "audio" or "video")
|
|
.Distinct()
|
|
.ToList()
|
|
})
|
|
.Take(MaxVariants)
|
|
.ToList();
|
|
|
|
static string Named(JsonNode node) => node switch
|
|
{
|
|
JsonValue value when value.TryGetValue<string>(out var text) => Text(text, 200),
|
|
JsonObject named => Text(Value(named, "name") ?? Value(named, "identifier"), 200),
|
|
_ => default
|
|
};
|
|
|
|
static DateTime? Moment(string value) =>
|
|
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) ? parsed.UtcDateTime : (DateTime?)null;
|
|
|
|
static bool IsWeb(string url) =>
|
|
Uri.TryCreate(url, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp);
|
|
|
|
static int? Int(JsonObject node, string property) =>
|
|
node?[property] is JsonValue value && value.TryGetValue<double>(out var number) && number is >= 0 and < int.MaxValue ? (int)number : (int?)null;
|
|
|
|
static long? Long(JsonObject node, string property) =>
|
|
node?[property] is JsonValue value && value.TryGetValue<double>(out var number) && number is >= 0 and < long.MaxValue ? (long)number : (long?)null;
|
|
|
|
static double? Double(JsonObject node, string property) =>
|
|
node?[property] is JsonValue value && value.TryGetValue<double>(out var number) && double.IsFinite(number) ? number : (double?)null;
|
|
|
|
static bool? Bool(JsonObject node, string property) =>
|
|
node?[property] is JsonValue value && value.TryGetValue<bool>(out var flag) ? flag : (bool?)null;
|
|
|
|
[GeneratedRegex(@"^[\w+\-.@]+$")]
|
|
private static partial Regex Shortcode();
|
|
|
|
[GeneratedRegex("<[^>]*>")]
|
|
private static partial Regex Tags();
|
|
|
|
[GeneratedRegex(@"\s+")]
|
|
private static partial Regex Spaces();
|
|
}
|
|
}
|