aboutsummaryrefslogtreecommitdiff
path: root/notes
diff options
context:
space:
mode:
authorGuillermo Serrahima <dev@vylion.com>2026-04-11 07:40:30 +0200
committerGuillermo Serrahima <dev@vylion.com>2026-04-11 23:39:19 +0200
commit9f0a9a6779f00786e4728ad5c3fad36814a293b0 (patch)
tree4b16d535c5d41ef46980608a88b7581be3c2bfd0 /notes
parentf6d60077b7cfc0d589fa7961e2bb59aea9f993a3 (diff)
downloadllostre-9f0a9a6779f00786e4728ad5c3fad36814a293b0.tar.xz
llostre-9f0a9a6779f00786e4728ad5c3fad36814a293b0.zip
project restructure
Diffstat (limited to 'notes')
-rw-r--r--notes/app.bsky.actor.go58
-rw-r--r--notes/app.bsky.feed.go150
-rw-r--r--notes/author.go11
-rw-r--r--notes/note.go80
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
+}