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