Write on the margins of the internet. Powered by the AT Protocol. margin.at
extension web atproto comments
at ui-refactor 400 lines 10 kB view raw
1package xrpc 2 3import ( 4 "encoding/json" 5 "fmt" 6 "time" 7 "unicode/utf8" 8) 9 10const ( 11 CollectionAnnotation = "at.margin.annotation" 12 CollectionHighlight = "at.margin.highlight" 13 CollectionBookmark = "at.margin.bookmark" 14 CollectionReply = "at.margin.reply" 15 CollectionLike = "at.margin.like" 16 CollectionCollection = "at.margin.collection" 17 CollectionCollectionItem = "at.margin.collectionItem" 18 CollectionProfile = "at.margin.profile" 19) 20 21const ( 22 SelectorTypeQuote = "TextQuoteSelector" 23 SelectorTypePosition = "TextPositionSelector" 24) 25 26type Selector struct { 27 Type string `json:"type"` 28} 29 30type TextQuoteSelector struct { 31 Type string `json:"type"` 32 Exact string `json:"exact"` 33 Prefix string `json:"prefix,omitempty"` 34 Suffix string `json:"suffix,omitempty"` 35} 36 37func (s *TextQuoteSelector) Validate() error { 38 if s.Type != SelectorTypeQuote { 39 return fmt.Errorf("invalid selector type: %s", s.Type) 40 } 41 if len(s.Exact) > 5000 { 42 return fmt.Errorf("exact text too long: %d > 5000", len(s.Exact)) 43 } 44 if len(s.Prefix) > 500 { 45 return fmt.Errorf("prefix too long: %d > 500", len(s.Prefix)) 46 } 47 if len(s.Suffix) > 500 { 48 return fmt.Errorf("suffix too long: %d > 500", len(s.Suffix)) 49 } 50 return nil 51} 52 53type TextPositionSelector struct { 54 Type string `json:"type"` 55 Start int `json:"start"` 56 End int `json:"end"` 57} 58 59func (s *TextPositionSelector) Validate() error { 60 if s.Type != SelectorTypePosition { 61 return fmt.Errorf("invalid selector type: %s", s.Type) 62 } 63 if s.Start < 0 { 64 return fmt.Errorf("start position cannot be negative") 65 } 66 if s.End < s.Start { 67 return fmt.Errorf("end position cannot be before start") 68 } 69 return nil 70} 71 72type AnnotationRecord struct { 73 Type string `json:"$type"` 74 Motivation string `json:"motivation,omitempty"` 75 Body *AnnotationBody `json:"body,omitempty"` 76 Target AnnotationTarget `json:"target"` 77 Tags []string `json:"tags,omitempty"` 78 Facets []Facet `json:"facets,omitempty"` 79 CreatedAt string `json:"createdAt"` 80} 81 82type Facet struct { 83 Index FacetIndex `json:"index"` 84 Features []FacetFeature `json:"features"` 85} 86 87type FacetIndex struct { 88 ByteStart int `json:"byteStart"` 89 ByteEnd int `json:"byteEnd"` 90} 91 92type FacetFeature struct { 93 Type string `json:"$type"` 94 Did string `json:"did,omitempty"` 95 Uri string `json:"uri,omitempty"` 96} 97 98type AnnotationBody struct { 99 Value string `json:"value,omitempty"` 100 Format string `json:"format,omitempty"` 101} 102 103type AnnotationTarget struct { 104 Source string `json:"source"` 105 SourceHash string `json:"sourceHash"` 106 Title string `json:"title,omitempty"` 107 Selector json.RawMessage `json:"selector,omitempty"` 108} 109 110func (r *AnnotationRecord) Validate() error { 111 if r.Target.Source == "" { 112 return fmt.Errorf("target source is required") 113 } 114 if r.Body != nil { 115 if len(r.Body.Value) > 10000 { 116 return fmt.Errorf("body too long: %d > 10000", len(r.Body.Value)) 117 } 118 if utf8.RuneCountInString(r.Body.Value) > 3000 { 119 return fmt.Errorf("body too long (graphemes): %d > 3000", utf8.RuneCountInString(r.Body.Value)) 120 } 121 } 122 if len(r.Tags) > 10 { 123 return fmt.Errorf("too many tags: %d > 10", len(r.Tags)) 124 } 125 for _, tag := range r.Tags { 126 if len(tag) > 64 { 127 return fmt.Errorf("tag too long: %s", tag) 128 } 129 } 130 131 if len(r.Target.Selector) > 0 { 132 var typeCheck Selector 133 if err := json.Unmarshal(r.Target.Selector, &typeCheck); err != nil { 134 return fmt.Errorf("invalid selector format") 135 } 136 137 switch typeCheck.Type { 138 case SelectorTypeQuote: 139 var s TextQuoteSelector 140 if err := json.Unmarshal(r.Target.Selector, &s); err != nil { 141 return err 142 } 143 return s.Validate() 144 case SelectorTypePosition: 145 var s TextPositionSelector 146 if err := json.Unmarshal(r.Target.Selector, &s); err != nil { 147 return err 148 } 149 return s.Validate() 150 } 151 } 152 153 return nil 154} 155 156func NewAnnotationRecord(url, urlHash, text string, selector interface{}, title string) *AnnotationRecord { 157 return NewAnnotationRecordWithMotivation(url, urlHash, text, selector, title, "commenting") 158} 159 160func NewAnnotationRecordWithMotivation(url, urlHash, text string, selector interface{}, title string, motivation string) *AnnotationRecord { 161 var selectorJSON json.RawMessage 162 if selector != nil { 163 b, _ := json.Marshal(selector) 164 selectorJSON = b 165 } 166 167 record := &AnnotationRecord{ 168 Type: CollectionAnnotation, 169 Motivation: motivation, 170 Target: AnnotationTarget{ 171 Source: url, 172 SourceHash: urlHash, 173 Title: title, 174 Selector: selectorJSON, 175 }, 176 CreatedAt: time.Now().UTC().Format(time.RFC3339), 177 } 178 179 if text != "" { 180 record.Body = &AnnotationBody{ 181 Value: text, 182 Format: "text/plain", 183 } 184 } 185 186 return record 187} 188 189type HighlightRecord struct { 190 Type string `json:"$type"` 191 Target AnnotationTarget `json:"target"` 192 Color string `json:"color,omitempty"` 193 Tags []string `json:"tags,omitempty"` 194 CreatedAt string `json:"createdAt"` 195} 196 197func (r *HighlightRecord) Validate() error { 198 if r.Target.Source == "" { 199 return fmt.Errorf("target source is required") 200 } 201 if len(r.Tags) > 10 { 202 return fmt.Errorf("too many tags: %d", len(r.Tags)) 203 } 204 if len(r.Color) > 20 { 205 return fmt.Errorf("color too long") 206 } 207 return nil 208} 209 210func NewHighlightRecord(url, urlHash string, selector interface{}, color string, tags []string) *HighlightRecord { 211 var selectorJSON json.RawMessage 212 if selector != nil { 213 b, _ := json.Marshal(selector) 214 selectorJSON = b 215 } 216 217 return &HighlightRecord{ 218 Type: CollectionHighlight, 219 Target: AnnotationTarget{ 220 Source: url, 221 SourceHash: urlHash, 222 Selector: selectorJSON, 223 }, 224 Color: color, 225 Tags: tags, 226 CreatedAt: time.Now().UTC().Format(time.RFC3339), 227 } 228} 229 230type ReplyRef struct { 231 URI string `json:"uri"` 232 CID string `json:"cid"` 233} 234 235type ReplyRecord struct { 236 Type string `json:"$type"` 237 Parent ReplyRef `json:"parent"` 238 Root ReplyRef `json:"root"` 239 Text string `json:"text"` 240 Format string `json:"format,omitempty"` 241 CreatedAt string `json:"createdAt"` 242} 243 244func (r *ReplyRecord) Validate() error { 245 if r.Text == "" { 246 return fmt.Errorf("text is required") 247 } 248 if len(r.Text) > 2000 { 249 return fmt.Errorf("reply text too long") 250 } 251 return nil 252} 253 254func NewReplyRecord(parentURI, parentCID, rootURI, rootCID, text string) *ReplyRecord { 255 return &ReplyRecord{ 256 Type: CollectionReply, 257 Parent: ReplyRef{URI: parentURI, CID: parentCID}, 258 Root: ReplyRef{URI: rootURI, CID: rootCID}, 259 Text: text, 260 Format: "text/plain", 261 CreatedAt: time.Now().UTC().Format(time.RFC3339), 262 } 263} 264 265type SubjectRef struct { 266 URI string `json:"uri"` 267 CID string `json:"cid"` 268} 269 270type LikeRecord struct { 271 Type string `json:"$type"` 272 Subject SubjectRef `json:"subject"` 273 CreatedAt string `json:"createdAt"` 274} 275 276func (r *LikeRecord) Validate() error { 277 if r.Subject.URI == "" || r.Subject.CID == "" { 278 return fmt.Errorf("invalid subject") 279 } 280 return nil 281} 282 283func NewLikeRecord(subjectURI, subjectCID string) *LikeRecord { 284 return &LikeRecord{ 285 Type: CollectionLike, 286 Subject: SubjectRef{URI: subjectURI, CID: subjectCID}, 287 CreatedAt: time.Now().UTC().Format(time.RFC3339), 288 } 289} 290 291type BookmarkRecord struct { 292 Type string `json:"$type"` 293 Source string `json:"source"` 294 SourceHash string `json:"sourceHash"` 295 Title string `json:"title,omitempty"` 296 Description string `json:"description,omitempty"` 297 Tags []string `json:"tags,omitempty"` 298 CreatedAt string `json:"createdAt"` 299} 300 301func (r *BookmarkRecord) Validate() error { 302 if r.Source == "" { 303 return fmt.Errorf("source is required") 304 } 305 if len(r.Title) > 500 { 306 return fmt.Errorf("title too long") 307 } 308 if len(r.Description) > 1000 { 309 return fmt.Errorf("description too long") 310 } 311 if len(r.Tags) > 10 { 312 return fmt.Errorf("too many tags") 313 } 314 return nil 315} 316 317func NewBookmarkRecord(url, urlHash, title, description string) *BookmarkRecord { 318 return &BookmarkRecord{ 319 Type: CollectionBookmark, 320 Source: url, 321 SourceHash: urlHash, 322 Title: title, 323 Description: description, 324 CreatedAt: time.Now().UTC().Format(time.RFC3339), 325 } 326} 327 328type CollectionRecord struct { 329 Type string `json:"$type"` 330 Name string `json:"name"` 331 Description string `json:"description,omitempty"` 332 Icon string `json:"icon,omitempty"` 333 CreatedAt string `json:"createdAt"` 334} 335 336func (r *CollectionRecord) Validate() error { 337 if r.Name == "" { 338 return fmt.Errorf("name is required") 339 } 340 if len(r.Name) > 100 { 341 return fmt.Errorf("name too long") 342 } 343 if len(r.Description) > 500 { 344 return fmt.Errorf("description too long") 345 } 346 return nil 347} 348 349func NewCollectionRecord(name, description, icon string) *CollectionRecord { 350 return &CollectionRecord{ 351 Type: CollectionCollection, 352 Name: name, 353 Description: description, 354 Icon: icon, 355 CreatedAt: time.Now().UTC().Format(time.RFC3339), 356 } 357} 358 359type CollectionItemRecord struct { 360 Type string `json:"$type"` 361 Collection string `json:"collection"` 362 Annotation string `json:"annotation"` 363 Position int `json:"position,omitempty"` 364 CreatedAt string `json:"createdAt"` 365} 366 367func (r *CollectionItemRecord) Validate() error { 368 if r.Collection == "" || r.Annotation == "" { 369 return fmt.Errorf("collection and annotation URIs required") 370 } 371 return nil 372} 373 374func NewCollectionItemRecord(collection, annotation string, position int) *CollectionItemRecord { 375 return &CollectionItemRecord{ 376 Type: CollectionCollectionItem, 377 Collection: collection, 378 Annotation: annotation, 379 Position: position, 380 CreatedAt: time.Now().UTC().Format(time.RFC3339), 381 } 382} 383 384type MarginProfileRecord struct { 385 Type string `json:"$type"` 386 Bio string `json:"bio,omitempty"` 387 Website string `json:"website,omitempty"` 388 Links []string `json:"links,omitempty"` 389 CreatedAt string `json:"createdAt"` 390} 391 392func (r *MarginProfileRecord) Validate() error { 393 if len(r.Bio) > 5000 { 394 return fmt.Errorf("bio too long") 395 } 396 if len(r.Links) > 20 { 397 return fmt.Errorf("too many links") 398 } 399 return nil 400}