Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1package api
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "log"
8 "net/http"
9 "strings"
10 "sync"
11 "time"
12
13 "margin.at/internal/db"
14 "margin.at/internal/xrpc"
15)
16
17func ensureSembleCardsIndexed(ctx context.Context, database *db.DB, uris []string) {
18 if len(uris) == 0 || database == nil {
19 return
20 }
21
22 uniq := make(map[string]struct{}, len(uris))
23 deduped := make([]string, 0, len(uris))
24 for _, u := range uris {
25 if u == "" {
26 continue
27 }
28 if _, ok := uniq[u]; ok {
29 continue
30 }
31 uniq[u] = struct{}{}
32 deduped = append(deduped, u)
33 }
34 if len(deduped) == 0 {
35 return
36 }
37
38 existingAnnos, _ := database.GetAnnotationsByURIs(deduped)
39 existingBooks, _ := database.GetBookmarksByURIs(deduped)
40
41 foundSet := make(map[string]bool, len(existingAnnos)+len(existingBooks))
42 for _, a := range existingAnnos {
43 foundSet[a.URI] = true
44 }
45 for _, b := range existingBooks {
46 foundSet[b.URI] = true
47 }
48
49 missing := make([]string, 0)
50 for _, u := range deduped {
51 if !foundSet[u] {
52 missing = append(missing, u)
53 }
54 }
55 if len(missing) == 0 {
56 return
57 }
58
59 log.Printf("Active Cache: Fetching %d missing Semble cards...", len(missing))
60 fetchAndIndexSembleCards(ctx, database, missing)
61}
62
63func fetchAndIndexSembleCards(ctx context.Context, database *db.DB, uris []string) {
64 sem := make(chan struct{}, 5)
65 var wg sync.WaitGroup
66
67 for _, uri := range uris {
68 select {
69 case <-ctx.Done():
70 return
71 default:
72 }
73
74 wg.Add(1)
75 go func(u string) {
76 defer wg.Done()
77
78 select {
79 case sem <- struct{}{}:
80 defer func() { <-sem }()
81 case <-ctx.Done():
82 return
83 }
84
85 if err := fetchSembleCard(ctx, database, u); err != nil {
86 if ctx.Err() == nil {
87 log.Printf("Failed to lazy fetch card %s: %v", u, err)
88 }
89 }
90 }(uri)
91 }
92
93 done := make(chan struct{})
94 go func() {
95 wg.Wait()
96 close(done)
97 }()
98
99 select {
100 case <-done:
101 case <-ctx.Done():
102 return
103 }
104}
105
106func fetchSembleCard(ctx context.Context, database *db.DB, uri string) error {
107 if database == nil {
108 return fmt.Errorf("nil database")
109 }
110
111 if !strings.HasPrefix(uri, "at://") {
112 return fmt.Errorf("invalid uri")
113 }
114 uriWithoutScheme := strings.TrimPrefix(uri, "at://")
115 parts := strings.Split(uriWithoutScheme, "/")
116 if len(parts) < 3 {
117 return fmt.Errorf("invalid uri parts: expected at least 3 parts")
118 }
119 did, collection, rkey := parts[0], parts[1], parts[2]
120
121 pds, err := xrpc.ResolveDIDToPDS(did)
122 if err != nil {
123 return fmt.Errorf("failed to resolve PDS: %w", err)
124 }
125
126 client := &http.Client{Timeout: 10 * time.Second}
127 url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", pds, did, collection, rkey)
128
129 req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
130 if err != nil {
131 return err
132 }
133
134 resp, err := client.Do(req)
135 if err != nil {
136 return fmt.Errorf("failed to fetch record: %w", err)
137 }
138 defer resp.Body.Close()
139
140 if resp.StatusCode != 200 {
141 return fmt.Errorf("unexpected status %d", resp.StatusCode)
142 }
143
144 var output xrpc.GetRecordOutput
145 if err := json.NewDecoder(resp.Body).Decode(&output); err != nil {
146 return err
147 }
148
149 var card xrpc.SembleCard
150 if err := json.Unmarshal(output.Value, &card); err != nil {
151 return err
152 }
153
154 createdAt := card.GetCreatedAtTime()
155 content, err := card.ParseContent()
156 if err != nil {
157 return err
158 }
159
160 switch card.Type {
161 case "NOTE":
162 note, ok := content.(*xrpc.SembleNoteContent)
163 if !ok {
164 return fmt.Errorf("invalid note content")
165 }
166
167 targetSource := card.URL
168 if targetSource == "" {
169 return fmt.Errorf("missing target source")
170 }
171
172 targetHash := db.HashURL(targetSource)
173 motivation := "commenting"
174 bodyValue := note.Text
175
176 annotation := &db.Annotation{
177 URI: uri,
178 AuthorDID: did,
179 Motivation: motivation,
180 BodyValue: &bodyValue,
181 TargetSource: targetSource,
182 TargetHash: targetHash,
183 CreatedAt: createdAt,
184 IndexedAt: time.Now(),
185 }
186 return database.CreateAnnotation(annotation)
187
188 case "URL":
189 urlContent, ok := content.(*xrpc.SembleURLContent)
190 if !ok {
191 return fmt.Errorf("invalid url content")
192 }
193
194 source := urlContent.URL
195 if source == "" {
196 return fmt.Errorf("missing source")
197 }
198 sourceHash := db.HashURL(source)
199
200 var titlePtr *string
201 if urlContent.Metadata != nil && urlContent.Metadata.Title != "" {
202 t := urlContent.Metadata.Title
203 titlePtr = &t
204 }
205
206 bookmark := &db.Bookmark{
207 URI: uri,
208 AuthorDID: did,
209 Source: source,
210 SourceHash: sourceHash,
211 Title: titlePtr,
212 CreatedAt: createdAt,
213 IndexedAt: time.Now(),
214 }
215 return database.CreateBookmark(bookmark)
216 }
217
218 return nil
219}