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 DateTime Published { get; init; } public DateTime? Updated { get; init; } public IReadOnlyList To { get; init; } = Array.Empty(); public IReadOnlyList Cc { get; init; } = Array.Empty(); public IReadOnlyList Mentions { get; init; } = Array.Empty(); public IReadOnlyList Tags { get; init; } = Array.Empty(); public IReadOnlyList Attachments { get; init; } = Array.Empty(); public PostSource Source { get; init; } 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 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(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 = Value(note, "quote") ?? Value(note, "quoteUrl") ?? Value(note, "quoteUri") ?? Value(note, "_misskey_quote"), 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), CoverURL = ObjectShapes.Cover(note), Link = ObjectShapes.Link(note), Video = ObjectShapes.Video(note), Audio = ObjectShapes.Audio(note), Event = ObjectShapes.Event(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(out var mapped)) content = mapped; } if (string.IsNullOrEmpty(content) && Value(note, "_misskey_content") is { } misskey) content = System.Net.WebUtility.HtmlEncode(misskey).Replace("\n", "
"); 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().Where(o => Value(o, "type") is "Person" or "Service" or "Application" or "Organization").Select(Id) .Concat(array.OfType().Select(Id)) .Concat(array.OfType().Where(o => Value(o, "type") == "Group").Select(Id)) .FirstOrDefault(id => id != default), _ => Id(node) }; static IEnumerable Tags(JsonObject note, string type) => (note["tag"] switch { JsonArray array => array.OfType(), JsonObject single => new[] { single }, _ => Enumerable.Empty() }).Where(t => Value(t, "type") == type); static List 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(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(out var d) ? (float)Math.Clamp(d, -1, 1) : 0f).ToArray() : default }; } static string FirstOf(JsonNode map) => map is JsonObject languages ? languages.Select(l => l.Value is JsonValue v && v.TryGetValue(out var text) ? text : default).FirstOrDefault(t => t != default) : default; static string UrlOf(JsonNode node) => node switch { JsonValue value when value.TryGetValue(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 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() }; 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(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]; } } }