Write on the margins of the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
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}