(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
98
fork

Configure Feed

Select the types of activity you want to include in your feed.

unfinished commit that i should prob finish

+463 -103
+18 -4
backend/internal/api/handler.go
··· 136 136 collectionService := NewCollectionService(h.db, h.refresher) 137 137 138 138 r.Route("/api", func(r chi.Router) { 139 - // Annotations 139 + // Notes 140 + r.Get("/notes", h.GetAnnotations) 141 + r.Get("/notes/feed", h.GetFeed) 142 + r.Get("/note", h.GetAnnotation) 143 + r.Get("/notes/history", h.GetEditHistory) 144 + r.Post("/notes", h.noteWriter.CreateAnnotation) 145 + r.Put("/notes", h.noteWriter.UpdateAnnotation) 146 + r.Delete("/notes", h.noteWriter.DeleteAnnotation) 147 + r.Post("/notes/like", h.noteWriter.LikeAnnotation) 148 + r.Delete("/notes/like", h.noteWriter.UnlikeAnnotation) 149 + r.Post("/notes/reply", h.noteWriter.CreateReply) 150 + r.Delete("/notes/reply", h.noteWriter.DeleteReply) 151 + r.Get("/replies", h.GetReplies) 152 + r.Get("/likes", h.GetLikeCount) 153 + 154 + // Annotations (legacy) 140 155 r.Get("/annotations", h.GetAnnotations) 141 156 r.Get("/annotations/feed", h.GetFeed) 142 157 r.Get("/annotation", h.GetAnnotation) ··· 148 163 r.Delete("/annotations/like", h.noteWriter.UnlikeAnnotation) 149 164 r.Post("/annotations/reply", h.noteWriter.CreateReply) 150 165 r.Delete("/annotations/reply", h.noteWriter.DeleteReply) 151 - r.Get("/replies", h.GetReplies) 152 - r.Get("/likes", h.GetLikeCount) 153 166 154 167 // Highlights 155 168 r.Get("/highlights", h.GetHighlights) ··· 181 194 r.Get("/url-metadata", h.GetURLMetadata) 182 195 183 196 // User content 184 - r.Get("/users/{did}/annotations", h.GetUserAnnotations) 197 + r.Get("/users/{did}/notes", h.GetUserAnnotations) 198 + r.Get("/users/{did}/annotations", h.GetUserAnnotations) // legacy 185 199 r.Get("/users/{did}/highlights", h.GetUserHighlights) 186 200 r.Get("/users/{did}/bookmarks", h.GetUserBookmarks) 187 201 r.Get("/users/{did}/targets", h.GetUserTargetItems)
+159 -9
backend/internal/api/hydration.go
··· 809 809 var annotationURIs []string 810 810 var highlightURIs []string 811 811 var bookmarkURIs []string 812 + var noteURIs []string 812 813 813 814 for _, item := range items { 814 815 collectionURIs = append(collectionURIs, item.CollectionURI) 815 - if strings.Contains(item.AnnotationURI, "at.margin.annotation") { 816 - annotationURIs = append(annotationURIs, item.AnnotationURI) 817 - } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { 818 - highlightURIs = append(highlightURIs, item.AnnotationURI) 819 - } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { 820 - bookmarkURIs = append(bookmarkURIs, item.AnnotationURI) 821 - } else if strings.Contains(item.AnnotationURI, "network.cosmik.card") { 822 - annotationURIs = append(annotationURIs, item.AnnotationURI) 823 - bookmarkURIs = append(bookmarkURIs, item.AnnotationURI) 816 + uri := item.AnnotationURI 817 + switch { 818 + case strings.Contains(uri, "at.margin.note"), 819 + strings.Contains(uri, "community.lexicon.bookmarks.bookmark"): 820 + noteURIs = append(noteURIs, uri) 821 + case strings.Contains(uri, "at.margin.annotation"): 822 + annotationURIs = append(annotationURIs, uri) 823 + case strings.Contains(uri, "at.margin.highlight"): 824 + highlightURIs = append(highlightURIs, uri) 825 + case strings.Contains(uri, "at.margin.bookmark"): 826 + bookmarkURIs = append(bookmarkURIs, uri) 827 + case strings.Contains(uri, "network.cosmik.card"): 828 + annotationURIs = append(annotationURIs, uri) 829 + bookmarkURIs = append(bookmarkURIs, uri) 824 830 } 825 831 } 826 832 ··· 862 868 var rawAnnos []db.Annotation 863 869 var rawHighlights []db.Highlight 864 870 var rawBookmarks []db.Bookmark 871 + var rawNotes []db.Note 865 872 873 + if len(noteURIs) > 0 { 874 + wg.Add(1) 875 + go func() { 876 + defer wg.Done() 877 + result, err := database.GetNotesByURIs(noteURIs) 878 + if err == nil { 879 + mu.Lock() 880 + rawNotes = result 881 + mu.Unlock() 882 + } 883 + }() 884 + } 866 885 if len(annotationURIs) > 0 { 867 886 wg.Add(1) 868 887 go func() { ··· 903 922 904 923 // Collect missing author DIDs from nested items and fetch their profiles 905 924 missingDIDs := make(map[string]bool) 925 + for _, n := range rawNotes { 926 + if _, ok := profiles[n.AuthorDID]; !ok { 927 + missingDIDs[n.AuthorDID] = true 928 + } 929 + } 906 930 for _, a := range rawAnnos { 907 931 if _, ok := profiles[a.AuthorDID]; !ok { 908 932 missingDIDs[a.AuthorDID] = true ··· 930 954 } 931 955 932 956 nestedShared := &hydrationData{profiles: profiles} 957 + 958 + if len(rawNotes) > 0 { 959 + wg.Add(1) 960 + go func() { 961 + defer wg.Done() 962 + uris := make([]string, len(rawNotes)) 963 + for i, n := range rawNotes { 964 + uris[i] = n.URI 965 + } 966 + authorDIDs := make([]string, len(rawNotes)) 967 + for i, n := range rawNotes { 968 + authorDIDs[i] = n.AuthorDID 969 + } 970 + likeCounts, replyCounts, viewerLikes, uriLabels, didLabels, _ := fetchEngagementData(database, uris, authorDIDs, viewerDID) 971 + mu.Lock() 972 + defer mu.Unlock() 973 + for _, n := range rawNotes { 974 + cid := "" 975 + if n.CID != nil { 976 + cid = *n.CID 977 + } 978 + var selector *APISelector 979 + if n.SelectorJSON != nil && *n.SelectorJSON != "" { 980 + selector = &APISelector{} 981 + json.Unmarshal([]byte(*n.SelectorJSON), selector) 982 + } 983 + var tags []string 984 + if n.TagsJSON != nil && *n.TagsJSON != "" { 985 + json.Unmarshal([]byte(*n.TagsJSON), &tags) 986 + } 987 + title := "" 988 + if n.TargetTitle != nil { 989 + title = *n.TargetTitle 990 + } 991 + labels := mergeLabels(uriLabels[n.URI], didLabels[n.AuthorDID]) 992 + generator := &APIGenerator{ID: "https://margin.at", Type: "Software", Name: "Margin"} 993 + 994 + switch n.Motivation { 995 + case "highlighting": 996 + color := "" 997 + if n.Color != nil { 998 + color = *n.Color 999 + } 1000 + h := APIHighlight{ 1001 + ID: n.URI, 1002 + Type: "Highlight", 1003 + Motivation: "highlighting", 1004 + Author: profiles[n.AuthorDID], 1005 + Target: APITarget{Source: n.TargetSource, Title: title, Selector: selector}, 1006 + Color: color, 1007 + Tags: tags, 1008 + CID: cid, 1009 + CreatedAt: n.CreatedAt, 1010 + Labels: labels, 1011 + LikeCount: likeCounts[n.URI], 1012 + ReplyCount: replyCounts[n.URI], 1013 + } 1014 + if viewerLikes != nil && viewerLikes[n.URI] { 1015 + h.ViewerHasLiked = true 1016 + } 1017 + highlightsMap[n.URI] = h 1018 + case "bookmarking": 1019 + desc := "" 1020 + if n.Description != nil { 1021 + desc = *n.Description 1022 + } 1023 + b := APIBookmark{ 1024 + ID: n.URI, 1025 + Type: "Bookmark", 1026 + Author: profiles[n.AuthorDID], 1027 + Source: n.TargetSource, 1028 + Title: title, 1029 + Description: desc, 1030 + Tags: tags, 1031 + CID: cid, 1032 + CreatedAt: n.CreatedAt, 1033 + Labels: labels, 1034 + LikeCount: likeCounts[n.URI], 1035 + ReplyCount: replyCounts[n.URI], 1036 + } 1037 + if viewerLikes != nil && viewerLikes[n.URI] { 1038 + b.ViewerHasLiked = true 1039 + } 1040 + bookmarksMap[n.URI] = b 1041 + default: 1042 + var body *APIBody 1043 + if n.BodyValue != nil || n.BodyURI != nil { 1044 + body = &APIBody{} 1045 + if n.BodyValue != nil { 1046 + body.Value = *n.BodyValue 1047 + } 1048 + if n.BodyFormat != nil { 1049 + body.Format = *n.BodyFormat 1050 + } 1051 + if n.BodyURI != nil { 1052 + body.URI = *n.BodyURI 1053 + } 1054 + } 1055 + motivation := n.Motivation 1056 + if motivation == "" { 1057 + motivation = "commenting" 1058 + } 1059 + a := APIAnnotation{ 1060 + ID: n.URI, 1061 + CID: cid, 1062 + Type: "Annotation", 1063 + Motivation: motivation, 1064 + Author: profiles[n.AuthorDID], 1065 + Body: body, 1066 + Target: APITarget{Source: n.TargetSource, Title: title, Selector: selector}, 1067 + Tags: tags, 1068 + Generator: generator, 1069 + CreatedAt: n.CreatedAt, 1070 + IndexedAt: n.IndexedAt, 1071 + Labels: labels, 1072 + LikeCount: likeCounts[n.URI], 1073 + ReplyCount: replyCounts[n.URI], 1074 + } 1075 + if viewerLikes != nil && viewerLikes[n.URI] { 1076 + a.ViewerHasLiked = true 1077 + } 1078 + annotationsMap[n.URI] = a 1079 + } 1080 + } 1081 + }() 1082 + } 933 1083 934 1084 if len(rawAnnos) > 0 { 935 1085 wg.Add(1)
+31 -2
backend/internal/api/notes.go
··· 140 140 return &NoteWriteService{db: &dbAdapter{d: database}, refresher: refresher} 141 141 } 142 142 143 + func (s *NoteWriteService) resolveCID(r *http.Request, uri string) string { 144 + if n, err := s.db.GetNoteByURI(uri); err == nil && n != nil && n.CID != nil { 145 + return *n.CID 146 + } 147 + if a, err := s.db.GetAnnotationByURI(uri); err == nil && a != nil && a.CID != nil { 148 + return *a.CID 149 + } 150 + if h, err := s.db.GetHighlightByURI(uri); err == nil && h != nil && h.CID != nil { 151 + return *h.CID 152 + } 153 + if b, err := s.db.GetBookmarkByURI(uri); err == nil && b != nil && b.CID != nil { 154 + return *b.CID 155 + } 156 + if rec, err := xrpc.SlingshotClient.GetRecord(r.Context(), uri); err == nil && rec.CID != "" { 157 + return rec.CID 158 + } 159 + 160 + return "" 161 + } 162 + 143 163 type CreateAnnotationRequest struct { 144 164 URL string `json:"url"` 145 165 Text string `json:"text"` ··· 582 602 return 583 603 } 584 604 585 - if req.SubjectURI == "" || req.SubjectCID == "" { 586 - WriteBadRequest(w, "subjectUri and subjectCid are required") 605 + if req.SubjectURI == "" { 606 + WriteBadRequest(w, "subjectUri is required") 607 + return 608 + } 609 + 610 + if req.SubjectCID == "" { 611 + req.SubjectCID = s.resolveCID(r, req.SubjectURI) 612 + } 613 + 614 + if req.SubjectCID == "" { 615 + WriteBadRequest(w, "could not resolve cid for subject") 587 616 return 588 617 } 589 618
+50
backend/internal/constellation/client.go
··· 94 94 Cursor string `json:"cursor,omitempty"` 95 95 } 96 96 97 + type ManyToManyCount struct { 98 + Subject string `json:"subject"` 99 + Count int `json:"count"` 100 + } 101 + 102 + type ManyToManyCountsResponse struct { 103 + Counts []ManyToManyCount `json:"counts"` 104 + Cursor string `json:"cursor,omitempty"` 105 + } 106 + 97 107 func (c *Client) getBacklinks(ctx context.Context, subject, source string, limit int) (*BacklinksResponse, error) { 98 108 params := url.Values{} 99 109 params.Set("subject", subject) ··· 125 135 return nil, fmt.Errorf("failed to decode response: %w", err) 126 136 } 127 137 138 + return &result, nil 139 + } 140 + 141 + func (c *Client) GetManyToManyCounts(ctx context.Context, subject, source, pathToOther string, filterDIDs, filterOtherSubjects []string, limit int) (*ManyToManyCountsResponse, error) { 142 + params := url.Values{} 143 + params.Set("subject", subject) 144 + params.Set("source", source) 145 + params.Set("pathToOther", pathToOther) 146 + for _, did := range filterDIDs { 147 + params.Add("did", did) 148 + } 149 + for _, other := range filterOtherSubjects { 150 + params.Add("otherSubject", other) 151 + } 152 + if limit > 0 { 153 + params.Set("limit", fmt.Sprintf("%d", limit)) 154 + } 155 + 156 + endpoint := fmt.Sprintf("%s/xrpc/blue.microcosm.links.getManyToManyCounts?%s", c.baseURL, params.Encode()) 157 + 158 + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 159 + if err != nil { 160 + return nil, fmt.Errorf("failed to create request: %w", err) 161 + } 162 + req.Header.Set("User-Agent", UserAgent) 163 + 164 + resp, err := c.httpClient.Do(req) 165 + if err != nil { 166 + return nil, fmt.Errorf("request failed: %w", err) 167 + } 168 + defer resp.Body.Close() 169 + 170 + if resp.StatusCode != http.StatusOK { 171 + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 172 + } 173 + 174 + var result ManyToManyCountsResponse 175 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 176 + return nil, fmt.Errorf("failed to decode response: %w", err) 177 + } 128 178 return &result, nil 129 179 } 130 180
+30
backend/internal/db/queries_collections.go
··· 221 221 return counts, nil 222 222 } 223 223 224 + func (db *DB) GetCollectionsForNoteURIs(noteURIs []string) (map[string]Collection, error) { 225 + if len(noteURIs) == 0 { 226 + return map[string]Collection{}, nil 227 + } 228 + rows, err := db.Query(` 229 + SELECT DISTINCT ON (ci.annotation_uri) 230 + ci.annotation_uri, 231 + c.uri, c.author_did, c.name, c.description, c.icon, c.created_at, c.indexed_at 232 + FROM collection_items ci 233 + JOIN collections c ON c.uri = ci.collection_uri 234 + WHERE ci.annotation_uri = ANY($1) 235 + ORDER BY ci.annotation_uri, ci.created_at ASC 236 + `, pqStringArray(noteURIs)) 237 + if err != nil { 238 + return nil, err 239 + } 240 + defer rows.Close() 241 + 242 + result := make(map[string]Collection) 243 + for rows.Next() { 244 + var noteURI string 245 + var c Collection 246 + if err := rows.Scan(&noteURI, &c.URI, &c.AuthorDID, &c.Name, &c.Description, &c.Icon, &c.CreatedAt, &c.IndexedAt); err != nil { 247 + return nil, err 248 + } 249 + result[noteURI] = c 250 + } 251 + return result, nil 252 + } 253 + 224 254 func (db *DB) GetCollectionsByURIs(uris []string) ([]Collection, error) { 225 255 if len(uris) == 0 { 226 256 return []Collection{}, nil
+35
backend/internal/db/queries_notes.go
··· 2 2 3 3 import ( 4 4 "database/sql" 5 + "fmt" 6 + "strings" 5 7 "time" 6 8 ) 7 9 ··· 95 97 return false, err 96 98 } 97 99 return true, nil 100 + } 101 + 102 + func (db *DB) GetNotesByURIs(uris []string) ([]Note, error) { 103 + if len(uris) == 0 { 104 + return nil, nil 105 + } 106 + placeholders := make([]string, len(uris)) 107 + args := make([]interface{}, len(uris)) 108 + for i, u := range uris { 109 + placeholders[i] = fmt.Sprintf("$%d", i+1) 110 + args[i] = u 111 + } 112 + query := ` 113 + SELECT uri, author_did, motivation, color, description, body_value, body_format, body_uri, 114 + target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid 115 + FROM notes WHERE uri IN (` + strings.Join(placeholders, ",") + `)` 116 + rows, err := db.Query(query, args...) 117 + if err != nil { 118 + return nil, err 119 + } 120 + defer rows.Close() 121 + var notes []Note 122 + for rows.Next() { 123 + var n Note 124 + if err := rows.Scan( 125 + &n.URI, &n.AuthorDID, &n.Motivation, &n.Color, &n.Description, &n.BodyValue, &n.BodyFormat, &n.BodyURI, 126 + &n.TargetSource, &n.TargetHash, &n.TargetTitle, &n.SelectorJSON, &n.TagsJSON, &n.CreatedAt, &n.IndexedAt, &n.CID, 127 + ); err != nil { 128 + return nil, err 129 + } 130 + notes = append(notes, n) 131 + } 132 + return notes, nil 98 133 } 99 134 100 135 func (db *DB) DeleteNote(uri string) error {
+7
backend/internal/service/hydration.go
··· 44 44 Name string `json:"name"` 45 45 } 46 46 47 + type APICollection struct { 48 + URI string `json:"uri"` 49 + Name string `json:"name"` 50 + Icon string `json:"icon,omitempty"` 51 + } 52 + 47 53 type APINote struct { 48 54 ID string `json:"id"` 49 55 CID string `json:"cid,omitempty"` ··· 63 69 ViewerHasLiked bool `json:"viewerHasLiked"` 64 70 Labels []APILabel `json:"labels,omitempty"` 65 71 EditedAt *time.Time `json:"editedAt,omitempty"` 72 + Collection *APICollection `json:"collection,omitempty"` 66 73 } 67 74 68 75 type LoadContext struct {
+117 -72
backend/internal/slingshot/client.go
··· 1 1 package slingshot 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 5 6 "encoding/json" 6 7 "fmt" ··· 39 40 } 40 41 41 42 type Identity struct { 42 - DID string `json:"did"` 43 - Handle string `json:"handle"` 44 - PDS string `json:"pds"` 43 + DID string `json:"did"` 44 + Handle string `json:"handle"` 45 + PDS string `json:"pds"` 46 + SigningKey string `json:"signing_key"` 45 47 } 46 48 47 49 type Record struct { ··· 50 52 Value json.RawMessage `json:"value"` 51 53 } 52 54 53 - func (c *Client) ResolveIdentity(ctx context.Context, identifier string) (*Identity, error) { 54 - params := url.Values{} 55 - params.Set("identifier", identifier) 55 + type HydrationSource struct { 56 + Path string `json:"path"` 57 + Shape string `json:"shape"` 58 + } 56 59 57 - endpoint := fmt.Sprintf("%s/xrpc/blue.microcosm.identity.resolveMiniDoc?%s", c.baseURL, params.Encode()) 60 + type HydratePayload struct { 61 + XRPC string `json:"xrpc"` 62 + AtprotoProxy string `json:"atproto_proxy"` 63 + Authorization string `json:"authorization,omitempty"` 64 + AtprotoAcceptLabelers string `json:"atproto_accept_labelers,omitempty"` 65 + Params any `json:"params,omitempty"` 66 + HydrationSources []HydrationSource `json:"hydration_sources"` 67 + } 58 68 69 + type HydrationResult struct { 70 + Status string `json:"status"` 71 + URI string `json:"uri,omitempty"` 72 + CID string `json:"cid,omitempty"` 73 + Value json.RawMessage `json:"value,omitempty"` 74 + FollowUp string `json:"followUp,omitempty"` 75 + Reason string `json:"reason,omitempty"` 76 + ShouldRetry bool `json:"shouldRetry,omitempty"` 77 + } 78 + 79 + type HydrateResponse struct { 80 + Output json.RawMessage `json:"output"` 81 + Records map[string]HydrationResult `json:"records"` 82 + Identifiers map[string]HydrationResult `json:"identifiers"` 83 + } 84 + 85 + func (c *Client) get(ctx context.Context, endpoint string, out interface{}) error { 59 86 req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 60 87 if err != nil { 61 - return nil, fmt.Errorf("failed to create request: %w", err) 88 + return fmt.Errorf("failed to create request: %w", err) 62 89 } 63 90 req.Header.Set("User-Agent", UserAgent) 64 91 65 92 resp, err := c.httpClient.Do(req) 66 93 if err != nil { 67 - return nil, fmt.Errorf("request failed: %w", err) 94 + return fmt.Errorf("request failed: %w", err) 68 95 } 69 96 defer resp.Body.Close() 70 97 71 98 if resp.StatusCode == http.StatusNotFound { 72 - return nil, fmt.Errorf("identity not found: %s", identifier) 99 + return fmt.Errorf("not found") 73 100 } 74 - 75 101 if resp.StatusCode != http.StatusOK { 76 - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 102 + var xrpcErr struct { 103 + Error string `json:"error"` 104 + Message string `json:"message"` 105 + } 106 + if jsonErr := json.NewDecoder(resp.Body).Decode(&xrpcErr); jsonErr == nil && xrpcErr.Error != "" { 107 + return fmt.Errorf("%s: %s", xrpcErr.Error, xrpcErr.Message) 108 + } 109 + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) 77 110 } 78 111 112 + return json.NewDecoder(resp.Body).Decode(out) 113 + } 114 + 115 + func (c *Client) ResolveIdentity(ctx context.Context, identifier string) (*Identity, error) { 116 + params := url.Values{} 117 + params.Set("identifier", identifier) 118 + endpoint := fmt.Sprintf("%s/xrpc/blue.microcosm.identity.resolveMiniDoc?%s", c.baseURL, params.Encode()) 119 + 79 120 var identity Identity 80 - if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil { 81 - return nil, fmt.Errorf("failed to decode response: %w", err) 121 + if err := c.get(ctx, endpoint, &identity); err != nil { 122 + return nil, fmt.Errorf("identity not found for %s: %w", identifier, err) 82 123 } 83 - 84 124 return &identity, nil 85 125 } 86 126 87 - func (c *Client) GetRecord(ctx context.Context, uri string) (*Record, error) { 88 - params := url.Values{} 89 - params.Set("at_uri", uri) 90 - 91 - endpoint := fmt.Sprintf("%s/xrpc/blue.microcosm.repo.getRecordByUri?%s", c.baseURL, params.Encode()) 92 - 93 - req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 127 + func (c *Client) ResolveHandle(ctx context.Context, handle string) (string, error) { 128 + identity, err := c.ResolveIdentity(ctx, handle) 94 129 if err != nil { 95 - return nil, fmt.Errorf("failed to create request: %w", err) 130 + return "", err 96 131 } 97 - req.Header.Set("User-Agent", UserAgent) 132 + return identity.DID, nil 133 + } 98 134 99 - resp, err := c.httpClient.Do(req) 135 + func (c *Client) ResolveDID(ctx context.Context, did string) (string, error) { 136 + identity, err := c.ResolveIdentity(ctx, did) 100 137 if err != nil { 101 - return nil, fmt.Errorf("request failed: %w", err) 138 + return "", err 102 139 } 103 - defer resp.Body.Close() 140 + return identity.PDS, nil 141 + } 104 142 105 - if resp.StatusCode == http.StatusNotFound { 106 - return nil, fmt.Errorf("record not found: %s", uri) 143 + func (c *Client) ResolveService(ctx context.Context, did, id, serviceType string) (string, error) { 144 + params := url.Values{} 145 + params.Set("did", did) 146 + params.Set("id", id) 147 + if serviceType != "" { 148 + params.Set("type", serviceType) 107 149 } 150 + endpoint := fmt.Sprintf("%s/xrpc/com.bad-example.identity.resolveService?%s", c.baseURL, params.Encode()) 108 151 109 - if resp.StatusCode != http.StatusOK { 110 - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 152 + var result struct { 153 + Endpoint string `json:"endpoint"` 111 154 } 155 + if err := c.get(ctx, endpoint, &result); err != nil { 156 + return "", fmt.Errorf("service not resolved for %s%s: %w", did, id, err) 157 + } 158 + return result.Endpoint, nil 159 + } 160 + 161 + func (c *Client) GetRecord(ctx context.Context, uri string) (*Record, error) { 162 + params := url.Values{} 163 + params.Set("at_uri", uri) 164 + endpoint := fmt.Sprintf("%s/xrpc/blue.microcosm.repo.getRecordByUri?%s", c.baseURL, params.Encode()) 112 165 113 166 var record Record 114 - if err := json.NewDecoder(resp.Body).Decode(&record); err != nil { 115 - return nil, fmt.Errorf("failed to decode response: %w", err) 167 + if err := c.get(ctx, endpoint, &record); err != nil { 168 + return nil, fmt.Errorf("record not found %s: %w", uri, err) 116 169 } 117 - 118 170 return &record, nil 119 171 } 120 172 121 - func (c *Client) GetRecordByParts(ctx context.Context, repo, collection, rkey string) (*Record, error) { 122 - uri := fmt.Sprintf("at://%s/%s/%s", repo, collection, rkey) 123 - return c.GetRecord(ctx, uri) 124 - } 125 - 126 - type ListRecordsResponse struct { 127 - Records []Record `json:"records"` 128 - Cursor string `json:"cursor,omitempty"` 129 - } 130 - 131 - func (c *Client) ListRecords(ctx context.Context, repo, collection string, limit int, cursor string) (*ListRecordsResponse, error) { 173 + func (c *Client) GetRecordStandard(ctx context.Context, repo, collection, rkey string) (*Record, error) { 132 174 params := url.Values{} 133 175 params.Set("repo", repo) 134 176 params.Set("collection", collection) 135 - if limit > 0 { 136 - params.Set("limit", fmt.Sprintf("%d", limit)) 137 - } 138 - if cursor != "" { 139 - params.Set("cursor", cursor) 177 + params.Set("rkey", rkey) 178 + endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?%s", c.baseURL, params.Encode()) 179 + 180 + var record Record 181 + if err := c.get(ctx, endpoint, &record); err != nil { 182 + return nil, fmt.Errorf("record not found %s/%s/%s: %w", repo, collection, rkey, err) 140 183 } 184 + return &record, nil 185 + } 141 186 142 - endpoint := fmt.Sprintf("%s/records?%s", c.baseURL, params.Encode()) 187 + func (c *Client) GetRecordByParts(ctx context.Context, repo, collection, rkey string) (*Record, error) { 188 + return c.GetRecord(ctx, fmt.Sprintf("at://%s/%s/%s", repo, collection, rkey)) 189 + } 143 190 144 - req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) 191 + func (c *Client) HydrateQueryResponse(ctx context.Context, payload HydratePayload) (*HydrateResponse, error) { 192 + body, err := json.Marshal(payload) 193 + if err != nil { 194 + return nil, fmt.Errorf("failed to marshal payload: %w", err) 195 + } 196 + 197 + endpoint := fmt.Sprintf("%s/xrpc/com.bad-example.proxy.hydrateQueryResponse", c.baseURL) 198 + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body)) 145 199 if err != nil { 146 200 return nil, fmt.Errorf("failed to create request: %w", err) 147 201 } 148 202 req.Header.Set("User-Agent", UserAgent) 203 + req.Header.Set("Content-Type", "application/json; charset=utf-8") 149 204 150 205 resp, err := c.httpClient.Do(req) 151 206 if err != nil { ··· 154 209 defer resp.Body.Close() 155 210 156 211 if resp.StatusCode != http.StatusOK { 212 + var xrpcErr struct { 213 + Error string `json:"error"` 214 + Message string `json:"message"` 215 + } 216 + if jsonErr := json.NewDecoder(resp.Body).Decode(&xrpcErr); jsonErr == nil && xrpcErr.Error != "" { 217 + return nil, fmt.Errorf("%s: %s", xrpcErr.Error, xrpcErr.Message) 218 + } 157 219 return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) 158 220 } 159 221 160 - var listResp ListRecordsResponse 161 - if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { 222 + var result HydrateResponse 223 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 162 224 return nil, fmt.Errorf("failed to decode response: %w", err) 163 225 } 164 - 165 - return &listResp, nil 166 - } 167 - 168 - func (c *Client) ResolveDID(ctx context.Context, did string) (string, error) { 169 - identity, err := c.ResolveIdentity(ctx, did) 170 - if err != nil { 171 - return "", err 172 - } 173 - return identity.PDS, nil 174 - } 175 - 176 - func (c *Client) ResolveHandle(ctx context.Context, handle string) (string, error) { 177 - identity, err := c.ResolveIdentity(ctx, handle) 178 - if err != nil { 179 - return "", err 180 - } 181 - return identity.DID, nil 226 + return &result, nil 182 227 }
+11 -11
web/src/api/client.ts
··· 293 293 if (tag) params.append("tag", tag); 294 294 if (creator) params.append("creator", creator); 295 295 296 - const endpoint = source ? "/api/targets" : "/api/annotations/feed"; 296 + const endpoint = source ? "/api/targets" : "/api/notes/feed"; 297 297 298 298 try { 299 299 const res = await apiRequest(`${endpoint}?${params.toString()}`, { ··· 359 359 labels, 360 360 }: CreateAnnotationParams) { 361 361 try { 362 - const res = await apiRequest("/api/annotations", { 362 + const res = await apiRequest("/api/notes", { 363 363 method: "POST", 364 364 body: JSON.stringify({ url, text, title, selector, tags, labels }), 365 365 }); ··· 467 467 468 468 export async function likeItem(uri: string, cid: string): Promise<boolean> { 469 469 try { 470 - const res = await apiRequest("/api/annotations/like", { 470 + const res = await apiRequest("/api/notes/like", { 471 471 method: "POST", 472 472 body: JSON.stringify({ subjectUri: uri, subjectCid: cid }), 473 473 }); ··· 481 481 export async function unlikeItem(uri: string): Promise<boolean> { 482 482 try { 483 483 const res = await apiRequest( 484 - `/api/annotations/like?uri=${encodeURIComponent(uri)}`, 484 + `/api/notes/like?uri=${encodeURIComponent(uri)}`, 485 485 { 486 486 method: "DELETE", 487 487 }, ··· 499 499 ): Promise<boolean> { 500 500 const rkey = (uri || "").split("/").pop(); 501 501 502 - let endpoint = "/api/annotations"; 502 + let endpoint = "/api/notes"; 503 503 if (type === "highlight" || uri.includes("highlight")) { 504 504 endpoint = "/api/highlights"; 505 505 } else if (type === "bookmark" || uri.includes("bookmark")) { ··· 525 525 title?: string, 526 526 ): Promise<{ success: boolean; item?: AnnotationItem; error?: string }> { 527 527 try { 528 - const createRes = await apiRequest("/api/annotations", { 528 + const createRes = await apiRequest("/api/notes", { 529 529 method: "POST", 530 530 body: JSON.stringify({ url, text, title, selector }), 531 531 }); ··· 558 558 ): Promise<boolean> { 559 559 try { 560 560 const res = await apiRequest( 561 - `/api/annotations?uri=${encodeURIComponent(uri)}`, 561 + `/api/notes?uri=${encodeURIComponent(uri)}`, 562 562 { 563 563 method: "PUT", 564 564 body: JSON.stringify({ text, tags, labels }), ··· 634 634 export async function getEditHistory(uri: string): Promise<EditHistoryItem[]> { 635 635 try { 636 636 const res = await apiRequest( 637 - `/api/annotations/history?uri=${encodeURIComponent(uri)}`, 637 + `/api/notes/history?uri=${encodeURIComponent(uri)}`, 638 638 ); 639 639 if (!res.ok) return []; 640 640 return await res.json(); ··· 994 994 text: string, 995 995 ): Promise<string | null> { 996 996 try { 997 - const res = await apiRequest("/api/annotations/reply", { 997 + const res = await apiRequest("/api/notes/reply", { 998 998 method: "POST", 999 999 body: JSON.stringify({ parentUri, parentCid, rootUri, rootCid, text }), 1000 1000 }); ··· 1010 1010 export async function deleteReply(uri: string): Promise<boolean> { 1011 1011 try { 1012 1012 const res = await apiRequest( 1013 - `/api/annotations/reply?uri=${encodeURIComponent(uri)}`, 1013 + `/api/notes/reply?uri=${encodeURIComponent(uri)}`, 1014 1014 { 1015 1015 method: "DELETE", 1016 1016 }, ··· 1027 1027 ): Promise<AnnotationItem | null> { 1028 1028 try { 1029 1029 const res = await apiRequest( 1030 - `/api/annotation?uri=${encodeURIComponent(uri)}`, 1030 + `/api/note?uri=${encodeURIComponent(uri)}`, 1031 1031 ); 1032 1032 if (!res.ok) return null; 1033 1033 return normalizeItem(await res.json());
+1 -1
web/src/components/modals/EditHistoryModal.tsx
··· 26 26 setLoading(true); 27 27 setError(null); 28 28 const res = await fetch( 29 - `/api/annotations/history?uri=${encodeURIComponent(item.uri)}`, 29 + `/api/notes/history?uri=${encodeURIComponent(item.uri)}`, 30 30 ); 31 31 if (!res.ok) throw new Error("Failed to fetch history"); 32 32 const data = await res.json();
+3 -3
web/src/lib/og.ts
··· 113 113 114 114 export async function fetchAnnotationOG(uri: string): Promise<OGData | null> { 115 115 const item = (await fetchJSON( 116 - `/api/annotation?uri=${encodeURIComponent(uri)}`, 116 + `/api/note?uri=${encodeURIComponent(uri)}`, 117 117 )) as APIAnnotation | null; 118 118 if (!item) return null; 119 119 ··· 151 151 152 152 export async function fetchHighlightOG(uri: string): Promise<OGData | null> { 153 153 const item = (await fetchJSON( 154 - `/api/annotation?uri=${encodeURIComponent(uri)}`, 154 + `/api/note?uri=${encodeURIComponent(uri)}`, 155 155 )) as APIAnnotation | null; 156 156 if (!item) return null; 157 157 ··· 186 186 187 187 export async function fetchBookmarkOG(uri: string): Promise<OGData | null> { 188 188 const item = (await fetchJSON( 189 - `/api/annotation?uri=${encodeURIComponent(uri)}`, 189 + `/api/note?uri=${encodeURIComponent(uri)}`, 190 190 )) as APIAnnotation | null; 191 191 if (!item) return null; 192 192
+1 -1
web/src/pages/og-image.ts
··· 78 78 async function fetchRecordData(uri: string): Promise<RecordData | null> { 79 79 try { 80 80 const res = await fetch( 81 - `${API_URL}/api/annotation?uri=${encodeURIComponent(uri)}`, 81 + `${API_URL}/api/note?uri=${encodeURIComponent(uri)}`, 82 82 ); 83 83 if (res.ok) { 84 84 const item = await res.json();