Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at ui-refactor 219 lines 4.6 kB view raw
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}