diff options
| author | Guillermo Serrahima <dev@vylion.com> | 2026-04-11 07:40:30 +0200 |
|---|---|---|
| committer | Guillermo Serrahima <dev@vylion.com> | 2026-04-11 23:39:19 +0200 |
| commit | 9f0a9a6779f00786e4728ad5c3fad36814a293b0 (patch) | |
| tree | 4b16d535c5d41ef46980608a88b7581be3c2bfd0 /notes | |
| parent | f6d60077b7cfc0d589fa7961e2bb59aea9f993a3 (diff) | |
| download | llostre-9f0a9a6779f00786e4728ad5c3fad36814a293b0.tar.xz llostre-9f0a9a6779f00786e4728ad5c3fad36814a293b0.zip | |
project restructure
Diffstat (limited to 'notes')
| -rw-r--r-- | notes/app.bsky.actor.go | 58 | ||||
| -rw-r--r-- | notes/app.bsky.feed.go | 150 | ||||
| -rw-r--r-- | notes/author.go | 11 | ||||
| -rw-r--r-- | notes/note.go | 80 |
4 files changed, 299 insertions, 0 deletions
diff --git a/notes/app.bsky.actor.go b/notes/app.bsky.actor.go new file mode 100644 index 0000000..00a1ff1 --- /dev/null +++ b/notes/app.bsky.actor.go @@ -0,0 +1,58 @@ +package notes + +import ( + "fmt" +) + +type AppBskyActorProfileRecord struct { + DisplayName string `json:"displayName"` + Avatar *struct { + Ref struct { + Link string `json:"$link"` + } `json:"ref"` + } `json:"avatar,omitempty"` + Banner *struct { + Ref struct { + Link string `json:"$link"` + } `json:"ref"` + } `json:"banner,omitempty"` + Pronouns string `json:"pronouns"` + Labels *struct { + Type string `json:"$type"` + Values []struct { + Val string `json:"val"` + } `json:"values"` + } `json:"labels,omitempty"` +} + +func AuthorFromAppBskyActorProfileRecord(record AppBskyActorProfileRecord, did, handle, pds string) (*Author, error) { + var avatarURL, bannerURL string + if record.Avatar != nil { + avatarURL = fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", pds, did, record.Avatar.Ref.Link) + } + if record.Banner != nil { + bannerURL = fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", pds, did, record.Banner.Ref.Link) + } + + // check for !no-unauthenticated label + noUnauth := false + if record.Labels != nil && record.Labels.Type == "com.atproto.label.defs#selfLabels" { + for _, label := range record.Labels.Values { + if label.Val == "!no-unauthenticated" { + noUnauth = true + } + } + } + + author := &Author{ + PDS: pds, + Handle: handle, + DisplayName: record.DisplayName, + AvatarURL: avatarURL, + BannerURL: bannerURL, + Pronouns: record.Pronouns, + NoUnauth: noUnauth, + } + + return author, nil +} diff --git a/notes/app.bsky.feed.go b/notes/app.bsky.feed.go new file mode 100644 index 0000000..21b94f9 --- /dev/null +++ b/notes/app.bsky.feed.go @@ -0,0 +1,150 @@ +package notes + +import ( + "fmt" + "llostre/general" + "time" +) + +type EmbedImage struct { + Alt string `json:"alt"` + Image struct { + CID string `json:"$link"` + } `json:"image"` + AspectRatio *struct { + Width int `json:"width"` + Height int `json:"height"` + } `json:"aspectRatio,omitempty"` +} + +type EmbedImages struct { + Type string `json:"$type"` + Images []EmbedImage `json:"images,omitempty"` +} + +type AppBskyFeedPostEmbed struct { + Type string `json:"$type"` + Images []EmbedImage `json:"images,omitempty"` + Media *EmbedImages `json:"media,omitempty"` +} + +type AppBskyFeedPostRecord struct { + URI string `json:"uri"` + Value struct { + Text string `json:"text"` + CreatedAt string `json:"createdAt"` + Reply *struct { + Parent struct { + URI string `json:"uri"` + } `json:"parent"` + } `json:"reply,omitempty"` + Facets []struct { + Index struct { + ByteStart int `json:"byteStart"` + ByteEnd int `json:"byteEnd"` + } `json:"index"` + Features []struct { + Type string `json:"$type"` + URI string `json:"uri,omitempty"` + DID string `json:"did,omitempty"` + Tag string `json:"tag,omitempty"` + } `json:"features"` + } `json:"facets,omitempty"` + Embed *AppBskyFeedPostEmbed `json:"embed,omitempty"` + } `json:"value"` +} + +func extractImages(embed *AppBskyFeedPostEmbed, did, pdsURL string) []Media { + if embed == nil { + return nil + } + images := embed.Images + if embed.Media != nil { + images = embed.Media.Images + } + var media []Media + for _, img := range images { + m := Media{ + URL: fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", pdsURL, did, img.Image.CID), + Alt: img.Alt, + } + if img.AspectRatio != nil { + m.Width = img.AspectRatio.Width + m.Height = img.AspectRatio.Height + } + media = append(media, m) + } + return media +} + +func NoteFromAppBskyFeedPostRecord(record AppBskyFeedPostRecord, author *Author) (*Note, error) { + if record.URI == "" { + return nil, fmt.Errorf("missing required field: uri") + } + + createdAt, err := time.Parse(time.RFC3339, record.Value.CreatedAt) + if err != nil { + return nil, fmt.Errorf("failed to parse timestamp: %w", err) + } + + var replyTo *string + if record.Value.Reply != nil { + replyTo = &record.Value.Reply.Parent.URI + } + + var facets []Facet + for _, f := range record.Value.Facets { + for _, feature := range f.Features { + var value string + var facetType string + + switch feature.Type { + case "app.bsky.richtext.facet#link": + facetType = "link" + value = feature.URI + case "app.bsky.richtext.facet#mention": + facetType = "mention" + value = feature.DID + case "app.bsky.richtext.facet#tag": + facetType = "tag" + value = feature.Tag + default: + continue + } + + facets = append(facets, Facet{ + ByteStart: f.Index.ByteStart, + ByteEnd: f.Index.ByteEnd, + Type: facetType, + Value: value, + }) + } + } + + var handle, displayName, avatarURL string + if author != nil { + handle = author.Handle + displayName = author.DisplayName + avatarURL = author.AvatarURL + } else { + handle = "handle.invalid" + displayName = "" + avatarURL = "" + } + + did, _, _, err := general.ParseURI(record.URI) + + return &Note{ + URI: record.URI, + AuthorDID: did, + AuthorHandle: handle, + AuthorName: displayName, + AuthorNoUnauth: author.NoUnauth, + AvatarURL: avatarURL, + CreatedAt: createdAt, + Text: record.Value.Text, + ReplyTo: replyTo, + Facets: facets, + Media: extractImages(record.Value.Embed, did, author.PDS), + }, nil +} diff --git a/notes/author.go b/notes/author.go new file mode 100644 index 0000000..faf4f7b --- /dev/null +++ b/notes/author.go @@ -0,0 +1,11 @@ +package notes + +type Author struct { + PDS string + Handle string + DisplayName string + AvatarURL string + BannerURL string + Pronouns string + NoUnauth bool +} diff --git a/notes/note.go b/notes/note.go new file mode 100644 index 0000000..15f6f67 --- /dev/null +++ b/notes/note.go @@ -0,0 +1,80 @@ +package notes + +import ( + "time" +) + +type Media struct { + URL string `json:"url"` + Alt string `json:"alt"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} + +type Note struct { + URI string `json:"uri"` + AuthorDID string `json:"authorDID"` + AuthorHandle string `json:"authorHandle"` + AuthorName string `json:"authorName"` + AuthorNoUnauth bool `json:"authorNoUnauth"` // author has !no-unauthenticated label + AvatarURL string `json:"avatarURL"` + Text string `json:"text"` + CreatedAt time.Time `json:"createdAt"` + ReplyTo *string `json:"replyTo,omitempty"` + Facets []Facet `json:"facets,omitempty"` + Parents []Note `json:"parents,omitempty"` + Media []Media `json:"media,omitempty"` +} + +type Facet struct { + ByteStart int `json:"byteStart"` + ByteEnd int `json:"byteEnd"` + Type string `json:"type"` + Value string `json:"value"` // the link destination, whether url, profile or tag +} + +func MissingNote(uri string) *Note { + return &Note{ + URI: uri, + AuthorDID: "MISSING", + Text: "This post could not be fetched. It may have been deleted.", + CreatedAt: time.Time{}, + } +} + +func isChainContained(smallChainPost, bigChainPost Note) bool { + if len(smallChainPost.Parents) > len(bigChainPost.Parents) { + return false + } + + // if the thread is going to differ at any post, it's going to be at the end + // thus if the posts match their parents MUST match too + // (not that I expect to ever compare a post to itself but this way it won't break if I do. And technically a thread contains itself) + if len(smallChainPost.Parents) == len(bigChainPost.Parents) { + return smallChainPost.URI == bigChainPost.URI + } + + // check if the last post of the smaller chain is the (n+1)-th post of the bigger chain + // where n is the number of parents in the smaller reply chain + offset := len(bigChainPost.Parents) - len(smallChainPost.Parents) + return smallChainPost.URI == bigChainPost.Parents[offset-1].URI +} + +func DeduplicateThreads(posts []Note) []Note { + filtered := []Note{} + for _, post := range posts { + isRedundant := false + for _, kept := range filtered { + // check if this post's chain is contained in any kept post + if isChainContained(post, kept) { + isRedundant = true + break + } + } + if !isRedundant { + filtered = append(filtered, post) + } + } + + return filtered +} |
