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 resp, err := http.Get("https://plc.directory/" + did)
484 if err != nil {
485 return "", err
486 }
487 defer resp.Body.Close()
488
489 var doc struct {
490 Service []struct {
491 Type string `json:"type"`
492 ServiceEndpoint string `json:"serviceEndpoint"`
493 } `json:"service"`
494 }
495 if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
496 return "", err
497 }
498
499 for _, svc := range doc.Service {
500 if svc.Type == "AtprotoPersonalDataServer" {
501 return svc.ServiceEndpoint, nil
502 }
503 }
504 }
505 return "", nil
506}
507
508type CreateHighlightRequest struct {
509 URL string `json:"url"`
510 Title string `json:"title,omitempty"`
511 Selector interface{} `json:"selector"`
512 Color string `json:"color,omitempty"`
513 Tags []string `json:"tags,omitempty"`
514}
515
516func (s *AnnotationService) CreateHighlight(w http.ResponseWriter, r *http.Request) {
517 session, err := s.refresher.GetSessionWithAutoRefresh(r)
518 if err != nil {
519 http.Error(w, err.Error(), http.StatusUnauthorized)
520 return
521 }
522
523 var req CreateHighlightRequest
524 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
525 http.Error(w, "Invalid request body", http.StatusBadRequest)
526 return
527 }
528
529 if req.URL == "" || req.Selector == nil {
530 http.Error(w, "URL and selector are required", http.StatusBadRequest)
531 return
532 }
533
534 urlHash := db.HashURL(req.URL)
535 record := xrpc.NewHighlightRecord(req.URL, urlHash, req.Selector, req.Color, req.Tags)
536
537 var result *xrpc.CreateRecordOutput
538 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
539 var createErr error
540 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionHighlight, record)
541 return createErr
542 })
543 if err != nil {
544 http.Error(w, "Failed to create highlight: "+err.Error(), http.StatusInternalServerError)
545 return
546 }
547
548 var selectorJSONPtr *string
549 if req.Selector != nil {
550 selectorBytes, _ := json.Marshal(req.Selector)
551 selectorStr := string(selectorBytes)
552 selectorJSONPtr = &selectorStr
553 }
554
555 var titlePtr *string
556 if req.Title != "" {
557 titlePtr = &req.Title
558 }
559
560 var colorPtr *string
561 if req.Color != "" {
562 colorPtr = &req.Color
563 }
564
565 var tagsJSONPtr *string
566 if len(req.Tags) > 0 {
567 tagsBytes, _ := json.Marshal(req.Tags)
568 tagsStr := string(tagsBytes)
569 tagsJSONPtr = &tagsStr
570 }
571
572 cid := result.CID
573 highlight := &db.Highlight{
574 URI: result.URI,
575 AuthorDID: session.DID,
576 TargetSource: req.URL,
577 TargetHash: urlHash,
578 TargetTitle: titlePtr,
579 SelectorJSON: selectorJSONPtr,
580 Color: colorPtr,
581 TagsJSON: tagsJSONPtr,
582 CreatedAt: time.Now(),
583 IndexedAt: time.Now(),
584 CID: &cid,
585 }
586 if err := s.db.CreateHighlight(highlight); err != nil {
587 http.Error(w, "Failed to index highlight", http.StatusInternalServerError)
588 return
589 }
590
591 w.Header().Set("Content-Type", "application/json")
592 json.NewEncoder(w).Encode(map[string]string{"uri": result.URI, "cid": result.CID})
593}
594
595type CreateBookmarkRequest struct {
596 URL string `json:"url"`
597 Title string `json:"title,omitempty"`
598 Description string `json:"description,omitempty"`
599}
600
601func (s *AnnotationService) CreateBookmark(w http.ResponseWriter, r *http.Request) {
602 session, err := s.refresher.GetSessionWithAutoRefresh(r)
603 if err != nil {
604 http.Error(w, err.Error(), http.StatusUnauthorized)
605 return
606 }
607
608 var req CreateBookmarkRequest
609 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
610 http.Error(w, "Invalid request body", http.StatusBadRequest)
611 return
612 }
613
614 if req.URL == "" {
615 http.Error(w, "URL is required", http.StatusBadRequest)
616 return
617 }
618
619 urlHash := db.HashURL(req.URL)
620 record := xrpc.NewBookmarkRecord(req.URL, urlHash, req.Title, req.Description)
621
622 var result *xrpc.CreateRecordOutput
623 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
624 var createErr error
625 result, createErr = client.CreateRecord(r.Context(), did, xrpc.CollectionBookmark, record)
626 return createErr
627 })
628 if err != nil {
629 http.Error(w, "Failed to create bookmark: "+err.Error(), http.StatusInternalServerError)
630 return
631 }
632
633 var titlePtr *string
634 if req.Title != "" {
635 titlePtr = &req.Title
636 }
637 var descPtr *string
638 if req.Description != "" {
639 descPtr = &req.Description
640 }
641
642 cid := result.CID
643 bookmark := &db.Bookmark{
644 URI: result.URI,
645 AuthorDID: session.DID,
646 Source: req.URL,
647 SourceHash: urlHash,
648 Title: titlePtr,
649 Description: descPtr,
650 CreatedAt: time.Now(),
651 IndexedAt: time.Now(),
652 CID: &cid,
653 }
654 s.db.CreateBookmark(bookmark)
655
656 w.Header().Set("Content-Type", "application/json")
657 json.NewEncoder(w).Encode(map[string]string{"uri": result.URI, "cid": result.CID})
658}
659
660func (s *AnnotationService) DeleteHighlight(w http.ResponseWriter, r *http.Request) {
661 session, err := s.refresher.GetSessionWithAutoRefresh(r)
662 if err != nil {
663 http.Error(w, err.Error(), http.StatusUnauthorized)
664 return
665 }
666
667 rkey := r.URL.Query().Get("rkey")
668 if rkey == "" {
669 http.Error(w, "rkey required", http.StatusBadRequest)
670 return
671 }
672
673 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
674 return client.DeleteRecord(r.Context(), did, xrpc.CollectionHighlight, rkey)
675 })
676 if err != nil {
677 http.Error(w, "Failed to delete highlight: "+err.Error(), http.StatusInternalServerError)
678 return
679 }
680
681 uri := "at://" + session.DID + "/" + xrpc.CollectionHighlight + "/" + rkey
682 s.db.DeleteHighlight(uri)
683
684 w.Header().Set("Content-Type", "application/json")
685 json.NewEncoder(w).Encode(map[string]bool{"success": true})
686}
687
688func (s *AnnotationService) DeleteBookmark(w http.ResponseWriter, r *http.Request) {
689 session, err := s.refresher.GetSessionWithAutoRefresh(r)
690 if err != nil {
691 http.Error(w, err.Error(), http.StatusUnauthorized)
692 return
693 }
694
695 rkey := r.URL.Query().Get("rkey")
696 if rkey == "" {
697 http.Error(w, "rkey required", http.StatusBadRequest)
698 return
699 }
700
701 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
702 return client.DeleteRecord(r.Context(), did, xrpc.CollectionBookmark, rkey)
703 })
704 if err != nil {
705 http.Error(w, "Failed to delete bookmark: "+err.Error(), http.StatusInternalServerError)
706 return
707 }
708
709 uri := "at://" + session.DID + "/" + xrpc.CollectionBookmark + "/" + rkey
710 s.db.DeleteBookmark(uri)
711
712 w.Header().Set("Content-Type", "application/json")
713 json.NewEncoder(w).Encode(map[string]bool{"success": true})
714}
715
716type UpdateHighlightRequest struct {
717 Color string `json:"color"`
718 Tags []string `json:"tags,omitempty"`
719}
720
721func (s *AnnotationService) UpdateHighlight(w http.ResponseWriter, r *http.Request) {
722 uri := r.URL.Query().Get("uri")
723 if uri == "" {
724 http.Error(w, "uri query parameter required", http.StatusBadRequest)
725 return
726 }
727
728 session, err := s.refresher.GetSessionWithAutoRefresh(r)
729 if err != nil {
730 http.Error(w, err.Error(), http.StatusUnauthorized)
731 return
732 }
733
734 if len(uri) < 5 || !strings.HasPrefix(uri[5:], session.DID) {
735 http.Error(w, "Not authorized", http.StatusForbidden)
736 return
737 }
738
739 var req UpdateHighlightRequest
740 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
741 http.Error(w, "Invalid request body", http.StatusBadRequest)
742 return
743 }
744
745 parts := parseATURI(uri)
746 if len(parts) < 3 {
747 http.Error(w, "Invalid URI", http.StatusBadRequest)
748 return
749 }
750 rkey := parts[2]
751
752 var result *xrpc.PutRecordOutput
753 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
754 existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionHighlight, rkey)
755 if getErr != nil {
756 return fmt.Errorf("failed to fetch record: %w", getErr)
757 }
758
759 var record map[string]interface{}
760 json.Unmarshal(existing.Value, &record)
761
762 if req.Color != "" {
763 record["color"] = req.Color
764 }
765 if req.Tags != nil {
766 record["tags"] = req.Tags
767 }
768
769 var updateErr error
770 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionHighlight, rkey, record)
771 if updateErr != nil {
772 log.Printf("UpdateHighlight failed: %v. Retrying with delete-then-create workaround.", updateErr)
773 _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionHighlight, rkey)
774 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionHighlight, rkey, record)
775 }
776 return updateErr
777 })
778
779 if err != nil {
780 http.Error(w, "Failed to update: "+err.Error(), http.StatusInternalServerError)
781 return
782 }
783
784 tagsJSON := ""
785 if req.Tags != nil {
786 b, _ := json.Marshal(req.Tags)
787 tagsJSON = string(b)
788 }
789 s.db.UpdateHighlight(uri, req.Color, tagsJSON, result.CID)
790
791 w.Header().Set("Content-Type", "application/json")
792 json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID})
793}
794
795type UpdateBookmarkRequest struct {
796 Title string `json:"title"`
797 Description string `json:"description"`
798 Tags []string `json:"tags,omitempty"`
799}
800
801func (s *AnnotationService) UpdateBookmark(w http.ResponseWriter, r *http.Request) {
802 uri := r.URL.Query().Get("uri")
803 if uri == "" {
804 http.Error(w, "uri query parameter required", http.StatusBadRequest)
805 return
806 }
807
808 session, err := s.refresher.GetSessionWithAutoRefresh(r)
809 if err != nil {
810 http.Error(w, err.Error(), http.StatusUnauthorized)
811 return
812 }
813
814 if len(uri) < 5 || !strings.HasPrefix(uri[5:], session.DID) {
815 http.Error(w, "Not authorized", http.StatusForbidden)
816 return
817 }
818
819 var req UpdateBookmarkRequest
820 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
821 http.Error(w, "Invalid request body", http.StatusBadRequest)
822 return
823 }
824
825 parts := parseATURI(uri)
826 if len(parts) < 3 {
827 http.Error(w, "Invalid URI", http.StatusBadRequest)
828 return
829 }
830 rkey := parts[2]
831
832 var result *xrpc.PutRecordOutput
833 err = s.refresher.ExecuteWithAutoRefresh(r, session, func(client *xrpc.Client, did string) error {
834 existing, getErr := client.GetRecord(r.Context(), did, xrpc.CollectionBookmark, rkey)
835 if getErr != nil {
836 return fmt.Errorf("failed to fetch record: %w", getErr)
837 }
838
839 var record map[string]interface{}
840 json.Unmarshal(existing.Value, &record)
841
842 if req.Title != "" {
843 record["title"] = req.Title
844 }
845 if req.Description != "" {
846 record["description"] = req.Description
847 }
848 if req.Tags != nil {
849 record["tags"] = req.Tags
850 }
851
852 var updateErr error
853 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionBookmark, rkey, record)
854 if updateErr != nil {
855 log.Printf("UpdateBookmark failed: %v. Retrying with delete-then-create workaround.", updateErr)
856 _ = client.DeleteRecord(r.Context(), did, xrpc.CollectionBookmark, rkey)
857 result, updateErr = client.PutRecord(r.Context(), did, xrpc.CollectionBookmark, rkey, record)
858 }
859 return updateErr
860 })
861
862 if err != nil {
863 http.Error(w, "Failed to update: "+err.Error(), http.StatusInternalServerError)
864 return
865 }
866
867 tagsJSON := ""
868 if req.Tags != nil {
869 b, _ := json.Marshal(req.Tags)
870 tagsJSON = string(b)
871 }
872 s.db.UpdateBookmark(uri, req.Title, req.Description, tagsJSON, result.CID)
873
874 w.Header().Set("Content-Type", "application/json")
875 json.NewEncoder(w).Encode(map[string]interface{}{"success": true, "uri": result.URI, "cid": result.CID})
876}