From d61f61ab9662a1b6da9f2daea58667e58ad9cdf3 Mon Sep 17 00:00:00 2001 From: scanash00 Date: Thu, 16 Apr 2026 04:30:57 -0800 Subject: [PATCH] unfinished commit that i should prob finish --- backend/internal/api/handler.go | 22 +- backend/internal/api/hydration.go | 168 +++++++++++++++- backend/internal/api/notes.go | 33 ++- backend/internal/constellation/client.go | 50 +++++ backend/internal/db/queries_collections.go | 30 +++ backend/internal/db/queries_notes.go | 35 ++++ backend/internal/service/hydration.go | 7 + backend/internal/slingshot/client.go | 189 +++++++++++------- web/src/api/client.ts | 22 +- .../components/modals/EditHistoryModal.tsx | 2 +- web/src/lib/og.ts | 6 +- web/src/pages/og-image.ts | 2 +- 12 files changed, 463 insertions(+), 103 deletions(-) diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go index 18251eb..8b2e30b 100644 --- a/backend/internal/api/handler.go +++ b/backend/internal/api/handler.go @@ -136,7 +136,22 @@ func (h *Handler) RegisterRoutes(r chi.Router) { collectionService := NewCollectionService(h.db, h.refresher) r.Route("/api", func(r chi.Router) { - // Annotations + // Notes + r.Get("/notes", h.GetAnnotations) + r.Get("/notes/feed", h.GetFeed) + r.Get("/note", h.GetAnnotation) + r.Get("/notes/history", h.GetEditHistory) + r.Post("/notes", h.noteWriter.CreateAnnotation) + r.Put("/notes", h.noteWriter.UpdateAnnotation) + r.Delete("/notes", h.noteWriter.DeleteAnnotation) + r.Post("/notes/like", h.noteWriter.LikeAnnotation) + r.Delete("/notes/like", h.noteWriter.UnlikeAnnotation) + r.Post("/notes/reply", h.noteWriter.CreateReply) + r.Delete("/notes/reply", h.noteWriter.DeleteReply) + r.Get("/replies", h.GetReplies) + r.Get("/likes", h.GetLikeCount) + + // Annotations (legacy) r.Get("/annotations", h.GetAnnotations) r.Get("/annotations/feed", h.GetFeed) r.Get("/annotation", h.GetAnnotation) @@ -148,8 +163,6 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Delete("/annotations/like", h.noteWriter.UnlikeAnnotation) r.Post("/annotations/reply", h.noteWriter.CreateReply) r.Delete("/annotations/reply", h.noteWriter.DeleteReply) - r.Get("/replies", h.GetReplies) - r.Get("/likes", h.GetLikeCount) // Highlights r.Get("/highlights", h.GetHighlights) @@ -181,7 +194,8 @@ func (h *Handler) RegisterRoutes(r chi.Router) { r.Get("/url-metadata", h.GetURLMetadata) // User content - r.Get("/users/{did}/annotations", h.GetUserAnnotations) + r.Get("/users/{did}/notes", h.GetUserAnnotations) + r.Get("/users/{did}/annotations", h.GetUserAnnotations) // legacy r.Get("/users/{did}/highlights", h.GetUserHighlights) r.Get("/users/{did}/bookmarks", h.GetUserBookmarks) r.Get("/users/{did}/targets", h.GetUserTargetItems) diff --git a/backend/internal/api/hydration.go b/backend/internal/api/hydration.go index 5b313c2..80e36a9 100644 --- a/backend/internal/api/hydration.go +++ b/backend/internal/api/hydration.go @@ -809,18 +809,24 @@ func hydrateCollectionItemsWithData(database *db.DB, items []db.CollectionItem, var annotationURIs []string var highlightURIs []string var bookmarkURIs []string + var noteURIs []string for _, item := range items { collectionURIs = append(collectionURIs, item.CollectionURI) - if strings.Contains(item.AnnotationURI, "at.margin.annotation") { - annotationURIs = append(annotationURIs, item.AnnotationURI) - } else if strings.Contains(item.AnnotationURI, "at.margin.highlight") { - highlightURIs = append(highlightURIs, item.AnnotationURI) - } else if strings.Contains(item.AnnotationURI, "at.margin.bookmark") { - bookmarkURIs = append(bookmarkURIs, item.AnnotationURI) - } else if strings.Contains(item.AnnotationURI, "network.cosmik.card") { - annotationURIs = append(annotationURIs, item.AnnotationURI) - bookmarkURIs = append(bookmarkURIs, item.AnnotationURI) + uri := item.AnnotationURI + switch { + case strings.Contains(uri, "at.margin.note"), + strings.Contains(uri, "community.lexicon.bookmarks.bookmark"): + noteURIs = append(noteURIs, uri) + case strings.Contains(uri, "at.margin.annotation"): + annotationURIs = append(annotationURIs, uri) + case strings.Contains(uri, "at.margin.highlight"): + highlightURIs = append(highlightURIs, uri) + case strings.Contains(uri, "at.margin.bookmark"): + bookmarkURIs = append(bookmarkURIs, uri) + case strings.Contains(uri, "network.cosmik.card"): + annotationURIs = append(annotationURIs, uri) + bookmarkURIs = append(bookmarkURIs, uri) } } @@ -862,7 +868,20 @@ func hydrateCollectionItemsWithData(database *db.DB, items []db.CollectionItem, var rawAnnos []db.Annotation var rawHighlights []db.Highlight var rawBookmarks []db.Bookmark + var rawNotes []db.Note + if len(noteURIs) > 0 { + wg.Add(1) + go func() { + defer wg.Done() + result, err := database.GetNotesByURIs(noteURIs) + if err == nil { + mu.Lock() + rawNotes = result + mu.Unlock() + } + }() + } if len(annotationURIs) > 0 { wg.Add(1) go func() { @@ -903,6 +922,11 @@ func hydrateCollectionItemsWithData(database *db.DB, items []db.CollectionItem, // Collect missing author DIDs from nested items and fetch their profiles missingDIDs := make(map[string]bool) + for _, n := range rawNotes { + if _, ok := profiles[n.AuthorDID]; !ok { + missingDIDs[n.AuthorDID] = true + } + } for _, a := range rawAnnos { if _, ok := profiles[a.AuthorDID]; !ok { missingDIDs[a.AuthorDID] = true @@ -931,6 +955,132 @@ func hydrateCollectionItemsWithData(database *db.DB, items []db.CollectionItem, nestedShared := &hydrationData{profiles: profiles} + if len(rawNotes) > 0 { + wg.Add(1) + go func() { + defer wg.Done() + uris := make([]string, len(rawNotes)) + for i, n := range rawNotes { + uris[i] = n.URI + } + authorDIDs := make([]string, len(rawNotes)) + for i, n := range rawNotes { + authorDIDs[i] = n.AuthorDID + } + likeCounts, replyCounts, viewerLikes, uriLabels, didLabels, _ := fetchEngagementData(database, uris, authorDIDs, viewerDID) + mu.Lock() + defer mu.Unlock() + for _, n := range rawNotes { + cid := "" + if n.CID != nil { + cid = *n.CID + } + var selector *APISelector + if n.SelectorJSON != nil && *n.SelectorJSON != "" { + selector = &APISelector{} + json.Unmarshal([]byte(*n.SelectorJSON), selector) + } + var tags []string + if n.TagsJSON != nil && *n.TagsJSON != "" { + json.Unmarshal([]byte(*n.TagsJSON), &tags) + } + title := "" + if n.TargetTitle != nil { + title = *n.TargetTitle + } + labels := mergeLabels(uriLabels[n.URI], didLabels[n.AuthorDID]) + generator := &APIGenerator{ID: "https://margin.at", Type: "Software", Name: "Margin"} + + switch n.Motivation { + case "highlighting": + color := "" + if n.Color != nil { + color = *n.Color + } + h := APIHighlight{ + ID: n.URI, + Type: "Highlight", + Motivation: "highlighting", + Author: profiles[n.AuthorDID], + Target: APITarget{Source: n.TargetSource, Title: title, Selector: selector}, + Color: color, + Tags: tags, + CID: cid, + CreatedAt: n.CreatedAt, + Labels: labels, + LikeCount: likeCounts[n.URI], + ReplyCount: replyCounts[n.URI], + } + if viewerLikes != nil && viewerLikes[n.URI] { + h.ViewerHasLiked = true + } + highlightsMap[n.URI] = h + case "bookmarking": + desc := "" + if n.Description != nil { + desc = *n.Description + } + b := APIBookmark{ + ID: n.URI, + Type: "Bookmark", + Author: profiles[n.AuthorDID], + Source: n.TargetSource, + Title: title, + Description: desc, + Tags: tags, + CID: cid, + CreatedAt: n.CreatedAt, + Labels: labels, + LikeCount: likeCounts[n.URI], + ReplyCount: replyCounts[n.URI], + } + if viewerLikes != nil && viewerLikes[n.URI] { + b.ViewerHasLiked = true + } + bookmarksMap[n.URI] = b + default: + var body *APIBody + if n.BodyValue != nil || n.BodyURI != nil { + body = &APIBody{} + if n.BodyValue != nil { + body.Value = *n.BodyValue + } + if n.BodyFormat != nil { + body.Format = *n.BodyFormat + } + if n.BodyURI != nil { + body.URI = *n.BodyURI + } + } + motivation := n.Motivation + if motivation == "" { + motivation = "commenting" + } + a := APIAnnotation{ + ID: n.URI, + CID: cid, + Type: "Annotation", + Motivation: motivation, + Author: profiles[n.AuthorDID], + Body: body, + Target: APITarget{Source: n.TargetSource, Title: title, Selector: selector}, + Tags: tags, + Generator: generator, + CreatedAt: n.CreatedAt, + IndexedAt: n.IndexedAt, + Labels: labels, + LikeCount: likeCounts[n.URI], + ReplyCount: replyCounts[n.URI], + } + if viewerLikes != nil && viewerLikes[n.URI] { + a.ViewerHasLiked = true + } + annotationsMap[n.URI] = a + } + } + }() + } + if len(rawAnnos) > 0 { wg.Add(1) go func() { diff --git a/backend/internal/api/notes.go b/backend/internal/api/notes.go index a8bd72b..6b4abbc 100644 --- a/backend/internal/api/notes.go +++ b/backend/internal/api/notes.go @@ -140,6 +140,26 @@ func NewNoteWriteService(database *db.DB, refresher *TokenRefresher) *NoteWriteS return &NoteWriteService{db: &dbAdapter{d: database}, refresher: refresher} } +func (s *NoteWriteService) resolveCID(r *http.Request, uri string) string { + if n, err := s.db.GetNoteByURI(uri); err == nil && n != nil && n.CID != nil { + return *n.CID + } + if a, err := s.db.GetAnnotationByURI(uri); err == nil && a != nil && a.CID != nil { + return *a.CID + } + if h, err := s.db.GetHighlightByURI(uri); err == nil && h != nil && h.CID != nil { + return *h.CID + } + if b, err := s.db.GetBookmarkByURI(uri); err == nil && b != nil && b.CID != nil { + return *b.CID + } + if rec, err := xrpc.SlingshotClient.GetRecord(r.Context(), uri); err == nil && rec.CID != "" { + return rec.CID + } + + return "" +} + type CreateAnnotationRequest struct { URL string `json:"url"` Text string `json:"text"` @@ -582,8 +602,17 @@ func (s *NoteWriteService) LikeAnnotation(w http.ResponseWriter, r *http.Request return } - if req.SubjectURI == "" || req.SubjectCID == "" { - WriteBadRequest(w, "subjectUri and subjectCid are required") + if req.SubjectURI == "" { + WriteBadRequest(w, "subjectUri is required") + return + } + + if req.SubjectCID == "" { + req.SubjectCID = s.resolveCID(r, req.SubjectURI) + } + + if req.SubjectCID == "" { + WriteBadRequest(w, "could not resolve cid for subject") return } diff --git a/backend/internal/constellation/client.go b/backend/internal/constellation/client.go index efb7c19..9931324 100644 --- a/backend/internal/constellation/client.go +++ b/backend/internal/constellation/client.go @@ -94,6 +94,16 @@ type BacklinksResponse struct { Cursor string `json:"cursor,omitempty"` } +type ManyToManyCount struct { + Subject string `json:"subject"` + Count int `json:"count"` +} + +type ManyToManyCountsResponse struct { + Counts []ManyToManyCount `json:"counts"` + Cursor string `json:"cursor,omitempty"` +} + func (c *Client) getBacklinks(ctx context.Context, subject, source string, limit int) (*BacklinksResponse, error) { params := url.Values{} params.Set("subject", subject) @@ -128,6 +138,46 @@ func (c *Client) getBacklinks(ctx context.Context, subject, source string, limit return &result, nil } +func (c *Client) GetManyToManyCounts(ctx context.Context, subject, source, pathToOther string, filterDIDs, filterOtherSubjects []string, limit int) (*ManyToManyCountsResponse, error) { + params := url.Values{} + params.Set("subject", subject) + params.Set("source", source) + params.Set("pathToOther", pathToOther) + for _, did := range filterDIDs { + params.Add("did", did) + } + for _, other := range filterOtherSubjects { + params.Add("otherSubject", other) + } + if limit > 0 { + params.Set("limit", fmt.Sprintf("%d", limit)) + } + + endpoint := fmt.Sprintf("%s/xrpc/blue.microcosm.links.getManyToManyCounts?%s", c.baseURL, params.Encode()) + + req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("User-Agent", UserAgent) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var result ManyToManyCountsResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + return &result, nil +} + func (c *Client) GetLikeCount(ctx context.Context, subjectURI string) (int, error) { return c.getBacklinksCount(ctx, subjectURI, "at.margin.like:subject.uri") } diff --git a/backend/internal/db/queries_collections.go b/backend/internal/db/queries_collections.go index e8dbfbf..958f4b9 100644 --- a/backend/internal/db/queries_collections.go +++ b/backend/internal/db/queries_collections.go @@ -221,6 +221,36 @@ func (db *DB) GetCollectionItemCounts(uris []string) (map[string]int, error) { return counts, nil } +func (db *DB) GetCollectionsForNoteURIs(noteURIs []string) (map[string]Collection, error) { + if len(noteURIs) == 0 { + return map[string]Collection{}, nil + } + rows, err := db.Query(` + SELECT DISTINCT ON (ci.annotation_uri) + ci.annotation_uri, + c.uri, c.author_did, c.name, c.description, c.icon, c.created_at, c.indexed_at + FROM collection_items ci + JOIN collections c ON c.uri = ci.collection_uri + WHERE ci.annotation_uri = ANY($1) + ORDER BY ci.annotation_uri, ci.created_at ASC + `, pqStringArray(noteURIs)) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[string]Collection) + for rows.Next() { + var noteURI string + var c Collection + if err := rows.Scan(¬eURI, &c.URI, &c.AuthorDID, &c.Name, &c.Description, &c.Icon, &c.CreatedAt, &c.IndexedAt); err != nil { + return nil, err + } + result[noteURI] = c + } + return result, nil +} + func (db *DB) GetCollectionsByURIs(uris []string) ([]Collection, error) { if len(uris) == 0 { return []Collection{}, nil diff --git a/backend/internal/db/queries_notes.go b/backend/internal/db/queries_notes.go index a004410..2e23cac 100644 --- a/backend/internal/db/queries_notes.go +++ b/backend/internal/db/queries_notes.go @@ -2,6 +2,8 @@ package db import ( "database/sql" + "fmt" + "strings" "time" ) @@ -97,6 +99,39 @@ func (db *DB) CommunityBookmarkExists(authorDID, targetHash, tagsJSON string) (b return true, nil } +func (db *DB) GetNotesByURIs(uris []string) ([]Note, error) { + if len(uris) == 0 { + return nil, nil + } + placeholders := make([]string, len(uris)) + args := make([]interface{}, len(uris)) + for i, u := range uris { + placeholders[i] = fmt.Sprintf("$%d", i+1) + args[i] = u + } + query := ` + SELECT uri, author_did, motivation, color, description, body_value, body_format, body_uri, + target_source, target_hash, target_title, selector_json, tags_json, created_at, indexed_at, cid + FROM notes WHERE uri IN (` + strings.Join(placeholders, ",") + `)` + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var notes []Note + for rows.Next() { + var n Note + if err := rows.Scan( + &n.URI, &n.AuthorDID, &n.Motivation, &n.Color, &n.Description, &n.BodyValue, &n.BodyFormat, &n.BodyURI, + &n.TargetSource, &n.TargetHash, &n.TargetTitle, &n.SelectorJSON, &n.TagsJSON, &n.CreatedAt, &n.IndexedAt, &n.CID, + ); err != nil { + return nil, err + } + notes = append(notes, n) + } + return notes, nil +} + func (db *DB) DeleteNote(uri string) error { _, err := db.Exec("DELETE FROM notes WHERE uri = $1", uri) return err diff --git a/backend/internal/service/hydration.go b/backend/internal/service/hydration.go index 23f9d7e..fbd0fab 100644 --- a/backend/internal/service/hydration.go +++ b/backend/internal/service/hydration.go @@ -44,6 +44,12 @@ type APIGenerator struct { Name string `json:"name"` } +type APICollection struct { + URI string `json:"uri"` + Name string `json:"name"` + Icon string `json:"icon,omitempty"` +} + type APINote struct { ID string `json:"id"` CID string `json:"cid,omitempty"` @@ -63,6 +69,7 @@ type APINote struct { ViewerHasLiked bool `json:"viewerHasLiked"` Labels []APILabel `json:"labels,omitempty"` EditedAt *time.Time `json:"editedAt,omitempty"` + Collection *APICollection `json:"collection,omitempty"` } type LoadContext struct { diff --git a/backend/internal/slingshot/client.go b/backend/internal/slingshot/client.go index defab1b..8402607 100644 --- a/backend/internal/slingshot/client.go +++ b/backend/internal/slingshot/client.go @@ -1,6 +1,7 @@ package slingshot import ( + "bytes" "context" "encoding/json" "fmt" @@ -39,9 +40,10 @@ func NewClientWithURL(baseURL string) *Client { } type Identity struct { - DID string `json:"did"` - Handle string `json:"handle"` - PDS string `json:"pds"` + DID string `json:"did"` + Handle string `json:"handle"` + PDS string `json:"pds"` + SigningKey string `json:"signing_key"` } type Record struct { @@ -50,102 +52,155 @@ type Record struct { Value json.RawMessage `json:"value"` } -func (c *Client) ResolveIdentity(ctx context.Context, identifier string) (*Identity, error) { - params := url.Values{} - params.Set("identifier", identifier) +type HydrationSource struct { + Path string `json:"path"` + Shape string `json:"shape"` +} - endpoint := fmt.Sprintf("%s/xrpc/blue.microcosm.identity.resolveMiniDoc?%s", c.baseURL, params.Encode()) +type HydratePayload struct { + XRPC string `json:"xrpc"` + AtprotoProxy string `json:"atproto_proxy"` + Authorization string `json:"authorization,omitempty"` + AtprotoAcceptLabelers string `json:"atproto_accept_labelers,omitempty"` + Params any `json:"params,omitempty"` + HydrationSources []HydrationSource `json:"hydration_sources"` +} +type HydrationResult struct { + Status string `json:"status"` + URI string `json:"uri,omitempty"` + CID string `json:"cid,omitempty"` + Value json.RawMessage `json:"value,omitempty"` + FollowUp string `json:"followUp,omitempty"` + Reason string `json:"reason,omitempty"` + ShouldRetry bool `json:"shouldRetry,omitempty"` +} + +type HydrateResponse struct { + Output json.RawMessage `json:"output"` + Records map[string]HydrationResult `json:"records"` + Identifiers map[string]HydrationResult `json:"identifiers"` +} + +func (c *Client) get(ctx context.Context, endpoint string, out interface{}) error { req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("User-Agent", UserAgent) resp, err := c.httpClient.Do(req) if err != nil { - return nil, fmt.Errorf("request failed: %w", err) + return fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("identity not found: %s", identifier) + return fmt.Errorf("not found") } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - var identity Identity - if err := json.NewDecoder(resp.Body).Decode(&identity); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + var xrpcErr struct { + Error string `json:"error"` + Message string `json:"message"` + } + if jsonErr := json.NewDecoder(resp.Body).Decode(&xrpcErr); jsonErr == nil && xrpcErr.Error != "" { + return fmt.Errorf("%s: %s", xrpcErr.Error, xrpcErr.Message) + } + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) } - return &identity, nil + return json.NewDecoder(resp.Body).Decode(out) } -func (c *Client) GetRecord(ctx context.Context, uri string) (*Record, error) { +func (c *Client) ResolveIdentity(ctx context.Context, identifier string) (*Identity, error) { params := url.Values{} - params.Set("at_uri", uri) + params.Set("identifier", identifier) + endpoint := fmt.Sprintf("%s/xrpc/blue.microcosm.identity.resolveMiniDoc?%s", c.baseURL, params.Encode()) - endpoint := fmt.Sprintf("%s/xrpc/blue.microcosm.repo.getRecordByUri?%s", c.baseURL, params.Encode()) + var identity Identity + if err := c.get(ctx, endpoint, &identity); err != nil { + return nil, fmt.Errorf("identity not found for %s: %w", identifier, err) + } + return &identity, nil +} - req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) +func (c *Client) ResolveHandle(ctx context.Context, handle string) (string, error) { + identity, err := c.ResolveIdentity(ctx, handle) if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + return "", err } - req.Header.Set("User-Agent", UserAgent) + return identity.DID, nil +} - resp, err := c.httpClient.Do(req) +func (c *Client) ResolveDID(ctx context.Context, did string) (string, error) { + identity, err := c.ResolveIdentity(ctx, did) if err != nil { - return nil, fmt.Errorf("request failed: %w", err) + return "", err } - defer resp.Body.Close() + return identity.PDS, nil +} - if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("record not found: %s", uri) +func (c *Client) ResolveService(ctx context.Context, did, id, serviceType string) (string, error) { + params := url.Values{} + params.Set("did", did) + params.Set("id", id) + if serviceType != "" { + params.Set("type", serviceType) } + endpoint := fmt.Sprintf("%s/xrpc/com.bad-example.identity.resolveService?%s", c.baseURL, params.Encode()) - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + var result struct { + Endpoint string `json:"endpoint"` } - - var record Record - if err := json.NewDecoder(resp.Body).Decode(&record); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + if err := c.get(ctx, endpoint, &result); err != nil { + return "", fmt.Errorf("service not resolved for %s%s: %w", did, id, err) } - - return &record, nil + return result.Endpoint, nil } -func (c *Client) GetRecordByParts(ctx context.Context, repo, collection, rkey string) (*Record, error) { - uri := fmt.Sprintf("at://%s/%s/%s", repo, collection, rkey) - return c.GetRecord(ctx, uri) -} +func (c *Client) GetRecord(ctx context.Context, uri string) (*Record, error) { + params := url.Values{} + params.Set("at_uri", uri) + endpoint := fmt.Sprintf("%s/xrpc/blue.microcosm.repo.getRecordByUri?%s", c.baseURL, params.Encode()) -type ListRecordsResponse struct { - Records []Record `json:"records"` - Cursor string `json:"cursor,omitempty"` + var record Record + if err := c.get(ctx, endpoint, &record); err != nil { + return nil, fmt.Errorf("record not found %s: %w", uri, err) + } + return &record, nil } -func (c *Client) ListRecords(ctx context.Context, repo, collection string, limit int, cursor string) (*ListRecordsResponse, error) { +func (c *Client) GetRecordStandard(ctx context.Context, repo, collection, rkey string) (*Record, error) { params := url.Values{} params.Set("repo", repo) params.Set("collection", collection) - if limit > 0 { - params.Set("limit", fmt.Sprintf("%d", limit)) - } - if cursor != "" { - params.Set("cursor", cursor) + params.Set("rkey", rkey) + endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?%s", c.baseURL, params.Encode()) + + var record Record + if err := c.get(ctx, endpoint, &record); err != nil { + return nil, fmt.Errorf("record not found %s/%s/%s: %w", repo, collection, rkey, err) } + return &record, nil +} - endpoint := fmt.Sprintf("%s/records?%s", c.baseURL, params.Encode()) +func (c *Client) GetRecordByParts(ctx context.Context, repo, collection, rkey string) (*Record, error) { + return c.GetRecord(ctx, fmt.Sprintf("at://%s/%s/%s", repo, collection, rkey)) +} - req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) +func (c *Client) HydrateQueryResponse(ctx context.Context, payload HydratePayload) (*HydrateResponse, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal payload: %w", err) + } + + endpoint := fmt.Sprintf("%s/xrpc/com.bad-example.proxy.hydrateQueryResponse", c.baseURL) + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("User-Agent", UserAgent) + req.Header.Set("Content-Type", "application/json; charset=utf-8") resp, err := c.httpClient.Do(req) if err != nil { @@ -154,29 +209,19 @@ func (c *Client) ListRecords(ctx context.Context, repo, collection string, limit defer resp.Body.Close() if resp.StatusCode != http.StatusOK { + var xrpcErr struct { + Error string `json:"error"` + Message string `json:"message"` + } + if jsonErr := json.NewDecoder(resp.Body).Decode(&xrpcErr); jsonErr == nil && xrpcErr.Error != "" { + return nil, fmt.Errorf("%s: %s", xrpcErr.Error, xrpcErr.Message) + } return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } - var listResp ListRecordsResponse - if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { + var result HydrateResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } - - return &listResp, nil -} - -func (c *Client) ResolveDID(ctx context.Context, did string) (string, error) { - identity, err := c.ResolveIdentity(ctx, did) - if err != nil { - return "", err - } - return identity.PDS, nil -} - -func (c *Client) ResolveHandle(ctx context.Context, handle string) (string, error) { - identity, err := c.ResolveIdentity(ctx, handle) - if err != nil { - return "", err - } - return identity.DID, nil + return &result, nil } diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 9d6d415..4009d9d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -293,7 +293,7 @@ export async function getFeed({ if (tag) params.append("tag", tag); if (creator) params.append("creator", creator); - const endpoint = source ? "/api/targets" : "/api/annotations/feed"; + const endpoint = source ? "/api/targets" : "/api/notes/feed"; try { const res = await apiRequest(`${endpoint}?${params.toString()}`, { @@ -359,7 +359,7 @@ export async function createAnnotation({ labels, }: CreateAnnotationParams) { try { - const res = await apiRequest("/api/annotations", { + const res = await apiRequest("/api/notes", { method: "POST", body: JSON.stringify({ url, text, title, selector, tags, labels }), }); @@ -467,7 +467,7 @@ export async function updateProfile(updates: { export async function likeItem(uri: string, cid: string): Promise { try { - const res = await apiRequest("/api/annotations/like", { + const res = await apiRequest("/api/notes/like", { method: "POST", body: JSON.stringify({ subjectUri: uri, subjectCid: cid }), }); @@ -481,7 +481,7 @@ export async function likeItem(uri: string, cid: string): Promise { export async function unlikeItem(uri: string): Promise { try { const res = await apiRequest( - `/api/annotations/like?uri=${encodeURIComponent(uri)}`, + `/api/notes/like?uri=${encodeURIComponent(uri)}`, { method: "DELETE", }, @@ -499,7 +499,7 @@ export async function deleteItem( ): Promise { const rkey = (uri || "").split("/").pop(); - let endpoint = "/api/annotations"; + let endpoint = "/api/notes"; if (type === "highlight" || uri.includes("highlight")) { endpoint = "/api/highlights"; } else if (type === "bookmark" || uri.includes("bookmark")) { @@ -525,7 +525,7 @@ export async function convertHighlightToAnnotation( title?: string, ): Promise<{ success: boolean; item?: AnnotationItem; error?: string }> { try { - const createRes = await apiRequest("/api/annotations", { + const createRes = await apiRequest("/api/notes", { method: "POST", body: JSON.stringify({ url, text, title, selector }), }); @@ -558,7 +558,7 @@ export async function updateAnnotation( ): Promise { try { const res = await apiRequest( - `/api/annotations?uri=${encodeURIComponent(uri)}`, + `/api/notes?uri=${encodeURIComponent(uri)}`, { method: "PUT", body: JSON.stringify({ text, tags, labels }), @@ -634,7 +634,7 @@ import type { EditHistoryItem } from "../types"; export async function getEditHistory(uri: string): Promise { try { const res = await apiRequest( - `/api/annotations/history?uri=${encodeURIComponent(uri)}`, + `/api/notes/history?uri=${encodeURIComponent(uri)}`, ); if (!res.ok) return []; return await res.json(); @@ -994,7 +994,7 @@ export async function createReply( text: string, ): Promise { try { - const res = await apiRequest("/api/annotations/reply", { + const res = await apiRequest("/api/notes/reply", { method: "POST", body: JSON.stringify({ parentUri, parentCid, rootUri, rootCid, text }), }); @@ -1010,7 +1010,7 @@ export async function createReply( export async function deleteReply(uri: string): Promise { try { const res = await apiRequest( - `/api/annotations/reply?uri=${encodeURIComponent(uri)}`, + `/api/notes/reply?uri=${encodeURIComponent(uri)}`, { method: "DELETE", }, @@ -1027,7 +1027,7 @@ export async function getAnnotation( ): Promise { try { const res = await apiRequest( - `/api/annotation?uri=${encodeURIComponent(uri)}`, + `/api/note?uri=${encodeURIComponent(uri)}`, ); if (!res.ok) return null; return normalizeItem(await res.json()); diff --git a/web/src/components/modals/EditHistoryModal.tsx b/web/src/components/modals/EditHistoryModal.tsx index 9c974ad..26eec19 100644 --- a/web/src/components/modals/EditHistoryModal.tsx +++ b/web/src/components/modals/EditHistoryModal.tsx @@ -26,7 +26,7 @@ export default function EditHistoryModal({ setLoading(true); setError(null); const res = await fetch( - `/api/annotations/history?uri=${encodeURIComponent(item.uri)}`, + `/api/notes/history?uri=${encodeURIComponent(item.uri)}`, ); if (!res.ok) throw new Error("Failed to fetch history"); const data = await res.json(); diff --git a/web/src/lib/og.ts b/web/src/lib/og.ts index 86d0684..8d4a876 100644 --- a/web/src/lib/og.ts +++ b/web/src/lib/og.ts @@ -113,7 +113,7 @@ const BASE_URL = process.env.BASE_URL || "https://margin.at"; export async function fetchAnnotationOG(uri: string): Promise { const item = (await fetchJSON( - `/api/annotation?uri=${encodeURIComponent(uri)}`, + `/api/note?uri=${encodeURIComponent(uri)}`, )) as APIAnnotation | null; if (!item) return null; @@ -151,7 +151,7 @@ export async function fetchAnnotationOG(uri: string): Promise { export async function fetchHighlightOG(uri: string): Promise { const item = (await fetchJSON( - `/api/annotation?uri=${encodeURIComponent(uri)}`, + `/api/note?uri=${encodeURIComponent(uri)}`, )) as APIAnnotation | null; if (!item) return null; @@ -186,7 +186,7 @@ export async function fetchHighlightOG(uri: string): Promise { export async function fetchBookmarkOG(uri: string): Promise { const item = (await fetchJSON( - `/api/annotation?uri=${encodeURIComponent(uri)}`, + `/api/note?uri=${encodeURIComponent(uri)}`, )) as APIAnnotation | null; if (!item) return null; diff --git a/web/src/pages/og-image.ts b/web/src/pages/og-image.ts index c223658..99901e3 100644 --- a/web/src/pages/og-image.ts +++ b/web/src/pages/og-image.ts @@ -78,7 +78,7 @@ async function fetchAvatarDataUri(did: string): Promise { async function fetchRecordData(uri: string): Promise { try { const res = await fetch( - `${API_URL}/api/annotation?uri=${encodeURIComponent(uri)}`, + `${API_URL}/api/note?uri=${encodeURIComponent(uri)}`, ); if (res.ok) { const item = await res.json(); -- 2.51.2