1package api
2
3import (
4 "encoding/json"
5 "fmt"
6 "log"
7 "net/http"
8 "strings"
9 "time"
10
11 "margin.at/internal/db"
12 "margin.at/internal/xrpc"
13)
14
15type AnnotationService struct {
16 db *db.DB
17 refresher *TokenRefresher
18}
19
20func NewAnnotationService(database *db.DB, refresher *TokenRefresher) *AnnotationService {
21 return &AnnotationService{db: database, refresher: refresher}
22}
23
24type CreateAnnotationRequest struct {
25 URL string `json:"url"`
26 Text string `json:"text"`
27 Selector interface{} `json:"selector,omitempty"`
28 Title string `json:"title,omitempty"`
29 Tags []string `json:"tags,omitempty"`
30}
31
32type CreateAnnotationResponse struct {
33 URI string `json:"uri"`
34 CID string `json:"cid"`
35}
36
37func (s *AnnotationService) CreateAnnotation(w http.ResponseWriter, r *http.Request) {
38 session, err := s.refresher.GetSessionWithAutoRefresh(r)
39 if err != nil {
40 http.Error(w, err.Error(), http.StatusUnauthorized)
41 return
42 }
43
44 var req CreateAnnotationRequest
45 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
46 http.Error(w, "Invalid request body", http.StatusBadRequest)
47 return
48 }
49
50 if req.URL == "" || req.Text == "" {
51 http.Error(w, "URL and text are required", http.StatusBadRequest)
52 return
53 }
54
55 if len(req.Text) > 3000 {
56 http.Error(w, "Text too long (max 3000 chars)", http.StatusBadRequest)
57 return
58 }
59
60 urlHash := db.HashURL(req.URL)
61
62 motivation := "commenting"
63 if req.Selector != nil && req.Text == "" {
64 motivation = "highlighting"
65 } else if len(req.Tags) > 0 {
66 motivation = "tagging"
67 }
68
69 record := xrpc.NewAnnotationRecordWithMotivation(req.URL, urlHash, req.Text, req.Selector, req.Title, motivation)
70
71 var result *xrpc.CreateRecordOutput
72 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
73 var createErr error
74 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionAnnotation, record)
75 return createErr
76 })
77 if err != nil {
78 http.Error(w, "Failed to create annotation: "+err.Error(), http.StatusInternalServerError)
79 return
80 }
81
82 bodyValue := req.Text
83 var bodyValuePtr, targetTitlePtr, selectorJSONPtr *string
84 if bodyValue != "" {
85 bodyValuePtr = &bodyValue
86 }
87 if req.Title != "" {
88 targetTitlePtr = &req.Title
89 }
90 if req.Selector != nil {
91 selectorBytes, _ := json.Marshal(req.Selector)
92 selectorStr := string(selectorBytes)
93 selectorJSONPtr = &selectorStr
94 }
95
96 cid := result.CID
97 did := session.DID
98 annotation := &db.Annotation{
99 URI: result.URI,
100 CID: &cid,
101 AuthorDID: did,
102 Motivation: motivation,
103 BodyValue: bodyValuePtr,
104 TargetSource: req.URL,
105 TargetHash: urlHash,
106 TargetTitle: targetTitlePtr,
107 SelectorJSON: selectorJSONPtr,
108 CreatedAt: time.Now(),
109 IndexedAt: time.Now(),
110 }
111
112 if err := s.db.CreateAnnotation(annotation); err != nil {
113 log.Printf("Warning: failed to index annotation in local DB: %v", err)
114 }
115
116 w.Header().Set("Content-Type", "application/json")
117 json.NewEncoder(w).Encode(CreateAnnotationResponse{
118 URI: result.URI,
119 CID: result.CID,
120 })
121}
122
123func (s *AnnotationService) DeleteAnnotation(w http.ResponseWriter, r *http.Request) {
124 session, err := s.refresher.GetSessionWithAutoRefresh(r)
125 if err != nil {
126 http.Error(w, err.Error(), http.StatusUnauthorized)
127 return
128 }
129
130 rkey := r.URL.Query().Get("rkey")
131 collectionType := r.URL.Query().Get("type")
132
133 if rkey == "" {
134 http.Error(w, "rkey required", http.StatusBadRequest)
135 return
136 }
137
138 collection := xrpc.CollectionAnnotation
139 if collectionType == "reply" {
140 collection = xrpc.CollectionReply
141 }
142
143 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
144 return client.DeleteRecord(r.Context(), did, collection, rkey)
145 })
146 if err != nil {
147 http.Error(w, "Failed to delete record: "+err.Error(), http.StatusInternalServerError)
148 return
149 }
150
151 did := session.DID
152 if collectionType == "reply" {
153 uri := "at://" + did + "/" + xrpc.CollectionReply + "/" + rkey
154 s.db.DeleteReply(uri)
155 } else {
156 uri := "at://" + did + "/" + xrpc.CollectionAnnotation + "/" + rkey
157 s.db.DeleteAnnotation(uri)
158 }
159
160 w.Header().Set("Content-Type", "application/json")
161 json.NewEncoder(w).Encode(map[string]bool{"success": true})
162}
163
164type UpdateAnnotationRequest struct {
165 Text string `json:"text"`
166 Tags []string `json:"tags"`
167}
168
169func (s *AnnotationService) UpdateAnnotation(w http.ResponseWriter, r *http.Request) {
170 uri := r.URL.Query().Get("uri")
171 if uri == "" {
172 http.Error(w, "uri query parameter required", http.StatusBadRequest)
173 return
174 }
175
176 session, err := s.refresher.GetSessionWithAutoRefresh(r)
177 if err != nil {
178 http.Error(w, err.Error(), http.StatusUnauthorized)
179 return
180 }
181
182 annotation, err := s.db.GetAnnotationByURI(uri)
183 if err != nil || annotation == nil {
184 http.Error(w, "Annotation not found", http.StatusNotFound)
185 return
186 }
187
188 if annotation.AuthorDID != session.DID {
189 http.Error(w, "Not authorized to edit this annotation", http.StatusForbidden)
190 return
191 }
192
193 var req UpdateAnnotationRequest
194 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
195 http.Error(w, "Invalid request body", http.StatusBadRequest)
196 return
197 }
198
199 parts := parseATURI(uri)
200 if len(parts) < 3 {
201 http.Error(w, "Invalid URI format", http.StatusBadRequest)
202 return
203 }
204 rkey := parts[2]
205
206 var selector interface{} = nil
207 if annotation.SelectorJSON != nil && *annotation.SelectorJSON != "" {
208 json.Unmarshal([]byte(*annotation.SelectorJSON), &selector)
209 }
210
211 tagsJSON := ""
212 if len(req.Tags) > 0 {
213 tagsBytes, _ := json.Marshal(req.Tags)
214 tagsJSON = string(tagsBytes)
215 }
216
217 record := map[string]interface{}{
218 "$type": xrpc.CollectionAnnotation,
219 "text": req.Text,
220 "url": annotation.TargetSource,
221 "createdAt": annotation.CreatedAt.Format(time.RFC3339),
222 }
223 if selector != nil {
224 record["selector"] = selector
225 }
226 if len(req.Tags) > 0 {
227 record["tags"] = req.Tags
228 }
229 if annotation.TargetTitle != nil {
230 record["title"] = *annotation.TargetTitle
231 }
232
233 if annotation.BodyValue != nil {
234 previousContent := *annotation.BodyValue
235 s.db.SaveEditHistory(uri, "annotation", previousContent, annotation.CID)
236 }
237
238 var result *xrpc.PutRecordOutput
239 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
240 var updateErr error
241 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record)
242 if updateErr != nil {
243 log.Printf("UpdateAnnotation failed: %v. Retrying with delete-then-create workaround.", updateErr)
244 _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey)
245 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionAnnotation, rkey, record)
246 }
247 return updateErr
248 })
249
250 if err != nil {
251 http.Error(w, "Failed to update record: "+err.Error(), http.StatusInternalServerError)
252 return
253 }
254
255 s.db.UpdateAnnotation(uri, req.Text, tagsJSON, result.CID)
256
257 w.Header().Set("Content-Type", "application/json")
258 json.NewEncoder(w).Encode(map[string]interface{}{
259 "success": true,
260 "uri": result.URI,
261 "cid": result.CID,
262 })
263}
264
265func parseATURI(uri string) []string {
266
267 if len(uri) < 5 || uri[:5] != "at://" {
268 return nil
269 }
270 return strings.Split(uri[5:], "/")
271}
272
273type CreateLikeRequest struct {
274 SubjectURI string `json:"subjectUri"`
275 SubjectCID string `json:"subjectCid"`
276}
277
278func (s *AnnotationService) LikeAnnotation(w http.ResponseWriter, r *http.Request) {
279 session, err := s.refresher.GetSessionWithAutoRefresh(r)
280 if err != nil {
281 http.Error(w, err.Error(), http.StatusUnauthorized)
282 return
283 }
284
285 var req CreateLikeRequest
286 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
287 http.Error(w, "Invalid request body", http.StatusBadRequest)
288 return
289 }
290
291 existingLike, _ := s.db.GetLikeByUserAndSubject(session.DID, req.SubjectURI)
292 if existingLike != nil {
293 w.Header().Set("Content-Type", "application/json")
294 json.NewEncoder(w).Encode(map[string]string{"uri": existingLike.URI, "existing": "true"})
295 return
296 }
297
298 record := xrpc.NewLikeRecord(req.SubjectURI, req.SubjectCID)
299
300 var result *xrpc.CreateRecordOutput
301 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
302 var createErr error
303 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionLike, record)
304 return createErr
305 })
306 if err != nil {
307 http.Error(w, "Failed to create like: "+err.Error(), http.StatusInternalServerError)
308 return
309 }
310
311 did := session.DID
312 like := &db.Like{
313 URI: result.URI,
314 AuthorDID: did,
315 SubjectURI: req.SubjectURI,
316 CreatedAt: time.Now(),
317 IndexedAt: time.Now(),
318 }
319 s.db.CreateLike(like)
320
321 if authorDID, err := s.db.GetAuthorByURI(req.SubjectURI); err == nil && authorDID != did {
322 s.db.CreateNotification(&db.Notification{
323 RecipientDID: authorDID,
324 ActorDID: did,
325 Type: "like",
326 SubjectURI: req.SubjectURI,
327 CreatedAt: time.Now(),
328 })
329 }
330
331 w.Header().Set("Content-Type", "application/json")
332 json.NewEncoder(w).Encode(map[string]string{"uri": result.URI})
333}
334
335func (s *AnnotationService) UnlikeAnnotation(w http.ResponseWriter, r *http.Request) {
336 session, err := s.refresher.GetSessionWithAutoRefresh(r)
337 if err != nil {
338 http.Error(w, err.Error(), http.StatusUnauthorized)
339 return
340 }
341
342 subjectURI := r.URL.Query().Get("uri")
343 if subjectURI == "" {
344 http.Error(w, "uri query parameter required", http.StatusBadRequest)
345 return
346 }
347
348 userLike, err := s.db.GetLikeByUserAndSubject(session.DID, subjectURI)
349 if err != nil {
350 http.Error(w, "Like not found", http.StatusNotFound)
351 return
352 }
353
354 parts := strings.Split(userLike.URI, "/")
355 rkey := parts[len(parts)-1]
356
357 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
358 return client.DeleteRecord(r.Context(), did, xrpc.CollectionLike, rkey)
359 })
360 if err != nil {
361 http.Error(w, "Failed to delete like: "+err.Error(), http.StatusInternalServerError)
362 return
363 }
364
365 s.db.DeleteLike(userLike.URI)
366
367 w.Header().Set("Content-Type", "application/json")
368 json.NewEncoder(w).Encode(map[string]bool{"success": true})
369}
370
371type CreateReplyRequest struct {
372 ParentURI string `json:"parentUri"`
373 ParentCID string `json:"parentCid"`
374 RootURI string `json:"rootUri"`
375 RootCID string `json:"rootCid"`
376 Text string `json:"text"`
377}
378
379func (s *AnnotationService) CreateReply(w http.ResponseWriter, r *http.Request) {
380 session, err := s.refresher.GetSessionWithAutoRefresh(r)
381 if err != nil {
382 http.Error(w, err.Error(), http.StatusUnauthorized)
383 return
384 }
385
386 var req CreateReplyRequest
387 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
388 http.Error(w, "Invalid request body", http.StatusBadRequest)
389 return
390 }
391
392 record := xrpc.NewReplyRecord(req.ParentURI, req.ParentCID, req.RootURI, req.RootCID, req.Text)
393
394 var result *xrpc.CreateRecordOutput
395 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
396 var createErr error
397 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionReply, record)
398 return createErr
399 })
400 if err != nil {
401 http.Error(w, "Failed to create reply: "+err.Error(), http.StatusInternalServerError)
402 return
403 }
404
405 reply := &db.Reply{
406 URI: result.URI,
407 AuthorDID: session.DID,
408 ParentURI: req.ParentURI,
409 RootURI: req.RootURI,
410 Text: req.Text,
411 CreatedAt: time.Now(),
412 IndexedAt: time.Now(),
413 CID: &result.CID,
414 }
415 s.db.CreateReply(reply)
416
417 if authorDID, err := s.db.GetAuthorByURI(req.ParentURI); err == nil && authorDID != session.DID {
418 s.db.CreateNotification(&db.Notification{
419 RecipientDID: authorDID,
420 ActorDID: session.DID,
421 Type: "reply",
422 SubjectURI: result.URI,
423 CreatedAt: time.Now(),
424 })
425 }
426
427 w.Header().Set("Content-Type", "application/json")
428 json.NewEncoder(w).Encode(map[string]string{"uri": result.URI})
429}
430
431func (s *AnnotationService) DeleteReply(w http.ResponseWriter, r *http.Request) {
432 uri := r.URL.Query().Get("uri")
433 if uri == "" {
434 http.Error(w, "uri query parameter required", http.StatusBadRequest)
435 return
436 }
437
438 session, err := s.refresher.GetSessionWithAutoRefresh(r)
439 if err != nil {
440 http.Error(w, err.Error(), http.StatusUnauthorized)
441 return
442 }
443
444 reply, err := s.db.GetReplyByURI(uri)
445 if err != nil || reply == nil {
446 http.Error(w, "reply not found", http.StatusNotFound)
447 return
448 }
449
450 if reply.AuthorDID != session.DID {
451 http.Error(w, "not authorized to delete this reply", http.StatusForbidden)
452 return
453 }
454
455 parts := strings.Split(uri, "/")
456 if len(parts) >= 2 {
457 rkey := parts[len(parts)-1]
458 _ = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
459 return client.DeleteRecord(r.Context(), did, "at.margin.reply", rkey)
460 })
461 }
462
463 s.db.DeleteReply(uri)
464
465 w.Header().Set("Content-Type", "application/json")
466 json.NewEncoder(w).Encode(map[string]bool{"success": true})
467}
468
469func resolveDIDToPDS(did string) (string, error) {
470 if strings.HasPrefix(did, "did:plc:") {
471 resp, err := http.Get("https://plc.directory/" + did)
472 if err != nil {
473 return "", err
474 }
475 defer resp.Body.Close()
476
477 var doc struct {
478 Service []struct {
479 Type string `json:"type"`
480 ServiceEndpoint string `json:"serviceEndpoint"`
481 } `json:"service"`
482 }
483 if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
484 return "", err
485 }
486
487 for _, svc := range doc.Service {
488 if svc.Type == "AtprotoPersonalDataServer" {
489 return svc.ServiceEndpoint, nil
490 }
491 }
492 }
493 return "", nil
494}
495
496type CreateHighlightRequest struct {
497 URL string `json:"url"`
498 Title string `json:"title,omitempty"`
499 Selector interface{} `json:"selector"`
500 Color string `json:"color,omitempty"`
501}
502
503func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) {
504 session, err := s.refresher.GetSessionWithAutoRefresh(r)
505 if err != nil {
506 http.Error(w, err.Error(), http.StatusUnauthorized)
507 return
508 }
509
510 var req CreateHighlightRequest
511 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
512 http.Error(w, "Invalid request body", http.StatusBadRequest)
513 return
514 }
515
516 if req.URL == "" || req.Selector == nil {
517 http.Error(w, "URL and selector are required", http.StatusBadRequest)
518 return
519 }
520
521 urlHash := db.HashURL(req.URL)
522 record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color)
523
524 var result *xrpc.CreateRecordOutput
525 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
526 var createErr error
527 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionHighlight, record)
528 return createErr
529 })
530 if err != nil {
531 http.Error(w, "Failed to create highlight: "+err.Error(), http.StatusInternalServerError)
532 return
533 }
534
535 var selectorJSONPtr *string
536 if req.Selector != nil {
537 selectorBytes, _ := json.Marshal(req.Selector)
538 selectorStr := string(selectorBytes)
539 selectorJSONPtr = &selectorStr
540 }
541
542 var titlePtr *string
543 if req.Title != "" {
544 titlePtr = &req.Title
545 }
546
547 var colorPtr *string
548 if req.Color != "" {
549 colorPtr = &req.Color
550 }
551
552 cid := result.CID
553 highlight := &db.Highlight{
554 URI: result.URI,
555 AuthorDID: session.DID,
556 TargetSource: req.URL,
557 TargetHash: urlHash,
558 TargetTitle: titlePtr,
559 SelectorJSON: selectorJSONPtr,
560 Color: colorPtr,
561 CreatedAt: time.Now(),
562 IndexedAt: time.Now(),
563 CID: &cid,
564 }
565 if err := s.db.CreateHighlight(highlight); err != nil {
566 http.Error(w, "Failed to index highlight", http.StatusInternalServerError)
567 return
568 }
569
570 w.Header().Set("Content-Type", "application/json")
571 json.NewEncoder(w).Encode(map[string]string{"uri": result.URI, "cid": result.CID})
572}
573
574type CreateBookmarkRequest struct {
575 URL string `json:"url"`
576 Title string `json:"title,omitempty"`
577 Description string `json:"description,omitempty"`
578}
579
580func (s *AnnotationService) CreateBookmark(w http.ResponseWriter, r *http.Request) {
581 session, err := s.refresher.GetSessionWithAutoRefresh(r)
582 if err != nil {
583 http.Error(w, err.Error(), http.StatusUnauthorized)
584 return
585 }
586
587 var req CreateBookmarkRequest
588 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
589 http.Error(w, "Invalid request body", http.StatusBadRequest)
590 return
591 }
592
593 if req.URL == "" {
594 http.Error(w, "URL is required", http.StatusBadRequest)
595 return
596 }
597
598 urlHash := db.HashURL(req.URL)
599 record := xrpc.NewBookmarkRecord(req.URL, urlHash, req.Title, req.Description)
600
601 var result *xrpc.CreateRecordOutput
602 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
603 var createErr error
604 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionBookmark, record)
605 return createErr
606 })
607 if err != nil {
608 http.Error(w, "Failed to create bookmark: "+err.Error(), http.StatusInternalServerError)
609 return
610 }
611
612 var titlePtr *string
613 if req.Title != "" {
614 titlePtr = &req.Title
615 }
616 var descPtr *string
617 if req.Description != "" {
618 descPtr = &req.Description
619 }
620
621 cid := result.CID
622 bookmark := &db.Bookmark{
623 URI: result.URI,
624 AuthorDID: session.DID,
625 Source: req.URL,
626 SourceHash: urlHash,
627 Title: titlePtr,
628 Description: descPtr,
629 CreatedAt: time.Now(),
630 IndexedAt: time.Now(),
631 CID: &cid,
632 }
633 s.db.CreateBookmark(bookmark)
634
635 w.Header().Set("Content-Type", "application/json")
636 json.NewEncoder(w).Encode(map[string]string{"uri": result.URI, "cid": result.CID})
637}
638
639func (s *AnnotationService) DeleteHighlight(w http.ResponseWriter, r *http.Request) {
640 session, err := s.refresher.GetSessionWithAutoRefresh(r)
641 if err != nil {
642 http.Error(w, err.Error(), http.StatusUnauthorized)
643 return
644 }
645
646 rkey := r.URL.Query().Get("rkey")
647 if rkey == "" {
648 http.Error(w, "rkey required", http.StatusBadRequest)
649 return
650 }
651
652 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
653 return client.DeleteRecord(r.Context(), did, xrpc.CollectionHighlight, rkey)
654 })
655 if err != nil {
656 http.Error(w, "Failed to delete highlight: "+err.Error(), http.StatusInternalServerError)
657 return
658 }
659
660 uri := "at://" + session.DID + "/" + xrpc.CollectionHighlight + "/" + rkey
661 s.db.DeleteHighlight(uri)
662
663 w.Header().Set("Content-Type", "application/json")
664 json.NewEncoder(w).Encode(map[string]bool{"success": true})
665}
666
667func (s *AnnotationService) DeleteBookmark(w http.ResponseWriter, r *http.Request) {
668 session, err := s.refresher.GetSessionWithAutoRefresh(r)
669 if err != nil {
670 http.Error(w, err.Error(), http.StatusUnauthorized)
671 return
672 }
673
674 rkey := r.URL.Query().Get("rkey")
675 if rkey == "" {
676 http.Error(w, "rkey required", http.StatusBadRequest)
677 return
678 }
679
680 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
681 return client.DeleteRecord(r.Context(), did, xrpc.CollectionBookmark, rkey)
682 })
683 if err != nil {
684 http.Error(w, "Failed to delete bookmark: "+err.Error(), http.StatusInternalServerError)
685 return
686 }
687
688 uri := "at://" + session.DID + "/" + xrpc.CollectionBookmark + "/" + rkey
689 s.db.DeleteBookmark(uri)
690
691 w.Header().Set("Content-Type", "application/json")
692 json.NewEncoder(w).Encode(map[string]bool{"success": true})
693}
694
695type UpdateHighlightRequest struct {
696 Color string `json:"color"`
697 Tags []string `json:"tags,omitempty"`
698}
699
700func (s *AnnotationService) UpdateHighlight(w http.ResponseWriter, r *http.Request) {
701 uri := r.URL.Query().Get("uri")
702 if uri == "" {
703 http.Error(w, "uri query parameter required", http.StatusBadRequest)
704 return
705 }
706
707 session, err := s.refresher.GetSessionWithAutoRefresh(r)
708 if err != nil {
709 http.Error(w, err.Error(), http.StatusUnauthorized)
710 return
711 }
712
713 if len(uri) < 5 || !strings.HasPrefix(uri[5:], session.DID) {
714 http.Error(w, "Not authorized", http.StatusForbidden)
715 return
716 }
717
718 var req UpdateHighlightRequest
719 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
720 http.Error(w, "Invalid request body", http.StatusBadRequest)
721 return
722 }
723
724 parts := parseATURI(uri)
725 if len(parts) < 3 {
726 http.Error(w, "Invalid URI", http.StatusBadRequest)
727 return
728 }
729 rkey := parts[2]
730
731 var result *xrpc.PutRecordOutput
732 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
733 existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionHighlight, rkey)
734 if getErr != nil {
735 return fmt.Errorf("failed to fetch record: %w", getErr)
736 }
737
738 var record map[string]interface{}
739 json.Unmarshal(existing.Value, &record)
740
741 if req.Color != "" {
742 record["color"] = req.Color
743 }
744 if req.Tags != nil {
745 record["tags"] = req.Tags
746 }
747
748 var updateErr error
749 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionHighlight, rkey, record)
750 if updateErr != nil {
751 log.Printf("UpdateHighlight failed: %v. Retrying with delete-then-create workaround.", updateErr)
752 _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionHighlight, rkey)
753 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionHighlight, rkey, record)
754 }
755 return updateErr
756 })
757
758 if err != nil {
759 http.Error(w, "Failed to update: "+err.Error(), http.StatusInternalServerError)
760 return
761 }
762
763 tagsJSON := ""
764 if req.Tags != nil {
765 b, _ := json.Marshal(req.Tags)
766 tagsJSON = string(b)
767 }
768 s.db.UpdateHighlight(uri, req.Color, tagsJSON, result.CID)
769
770 w.Header().Set("Content-Type", "application/json")
771 json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID})
772}
773
774type UpdateBookmarkRequest struct {
775 Title string `json:"title"`
776 Description string `json:"description"`
777 Tags []string `json:"tags,omitempty"`
778}
779
780func (s *AnnotationService) UpdateBookmark(w http.ResponseWriter, r *http.Request) {
781 uri := r.URL.Query().Get("uri")
782 if uri == "" {
783 http.Error(w, "uri query parameter required", http.StatusBadRequest)
784 return
785 }
786
787 session, err := s.refresher.GetSessionWithAutoRefresh(r)
788 if err != nil {
789 http.Error(w, err.Error(), http.StatusUnauthorized)
790 return
791 }
792
793 if len(uri) < 5 || !strings.HasPrefix(uri[5:], session.DID) {
794 http.Error(w, "Not authorized", http.StatusForbidden)
795 return
796 }
797
798 var req UpdateBookmarkRequest
799 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
800 http.Error(w, "Invalid request body", http.StatusBadRequest)
801 return
802 }
803
804 parts := parseATURI(uri)
805 if len(parts) < 3 {
806 http.Error(w, "Invalid URI", http.StatusBadRequest)
807 return
808 }
809 rkey := parts[2]
810
811 var result *xrpc.PutRecordOutput
812 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
813 existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionBookmark, rkey)
814 if getErr != nil {
815 return fmt.Errorf("failed to fetch record: %w", getErr)
816 }
817
818 var record map[string]interface{}
819 json.Unmarshal(existing.Value, &record)
820
821 if req.Title != "" {
822 record["title"] = req.Title
823 }
824 if req.Description != "" {
825 record["description"] = req.Description
826 }
827 if req.Tags != nil {
828 record["tags"] = req.Tags
829 }
830
831 var updateErr error
832 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionBookmark, rkey, record)
833 if updateErr != nil {
834 log.Printf("UpdateBookmark failed: %v. Retrying with delete-then-create workaround.", updateErr)
835 _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionBookmark, rkey)
836 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionBookmark, rkey, record)
837 }
838 return updateErr
839 })
840
841 if err != nil {
842 http.Error(w, "Failed to update: "+err.Error(), http.StatusInternalServerError)
843 return
844 }
845
846 tagsJSON := ""
847 if req.Tags != nil {
848 b, _ := json.Marshal(req.Tags)
849 tagsJSON = string(b)
850 }
851 s.db.UpdateBookmark(uri, req.Title, req.Description, tagsJSON, result.CID)
852
853 w.Header().Set("Content-Type", "application/json")
854 json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID})
855}