a love letter to tangled (android, iOS, and a search API)
at main 128 lines 3.4 kB view raw
1package normalize 2 3import ( 4 "encoding/json" 5 "fmt" 6 "strings" 7 8 "tangled.org/desertthunder.dev/twister/internal/store" 9) 10 11// TapRecord is the inner record object from a Tap event. 12type TapRecord struct { 13 Live bool `json:"live"` 14 Rev string `json:"rev"` 15 DID string `json:"did"` 16 Collection string `json:"collection"` 17 RKey string `json:"rkey"` 18 Action string `json:"action"` 19 CID string `json:"cid"` 20 Record map[string]any `json:"record"` 21} 22 23// TapIdentity is the identity payload from a Tap identity event. 24type TapIdentity struct { 25 DID string `json:"did"` 26 Handle string `json:"handle"` 27 IsActive bool `json:"isActive"` 28 Status string `json:"status"` 29} 30 31// TapRecordEvent is the top-level event received from the Tap WebSocket. 32type TapRecordEvent struct { 33 ID int64 `json:"id"` 34 Type string `json:"type"` 35 Record *TapRecord `json:"record,omitempty"` 36 Identity *TapIdentity `json:"identity,omitempty"` 37} 38 39// RecordAdapter normalizes a Tap record event into a search Document. 40type RecordAdapter interface { 41 Collection() string 42 RecordType() string 43 Normalize(event TapRecordEvent) (*store.Document, error) 44 Searchable(record map[string]any) bool 45} 46 47// StateUpdate represents a record_state change derived from a state/status record. 48type StateUpdate struct { 49 SubjectURI string 50 State string 51} 52 53// StateHandler processes state/status records that update record_state, 54// not the documents table. 55type StateHandler interface { 56 Collection() string 57 HandleState(event TapRecordEvent) (*StateUpdate, error) 58} 59 60// StableID returns the canonical document ID: did|collection|rkey. 61func StableID(did, collection, rkey string) string { 62 return fmt.Sprintf("%s|%s|%s", did, collection, rkey) 63} 64 65// BuildATURI constructs the AT-URI for a record. 66func BuildATURI(did, collection, rkey string) string { 67 return fmt.Sprintf("at://%s/%s/%s", did, collection, rkey) 68} 69 70// ParseATURI extracts the DID, collection, and rkey from an AT-URI of the 71// form at://did:plc:abc123/sh.tangled.repo/3kb3fge5lm32x. 72func ParseATURI(uri string) (did, collection, rkey string, err error) { 73 trimmed := strings.TrimPrefix(uri, "at://") 74 parts := strings.SplitN(trimmed, "/", 3) 75 if len(parts) != 3 || parts[0] == "" { 76 return "", "", "", fmt.Errorf("invalid AT-URI: %q", uri) 77 } 78 return parts[0], parts[1], parts[2], nil 79} 80 81// truncate returns s truncated to at most n bytes. 82func truncate(s string, n int) string { 83 if len(s) <= n { 84 return s 85 } 86 return s[:n] 87} 88 89// marshalTags serializes a value as JSON, returning "[]" on nil or error. 90func marshalTags(v any) string { 91 if v == nil { 92 return "[]" 93 } 94 b, err := json.Marshal(v) 95 if err != nil { 96 return "[]" 97 } 98 return string(b) 99} 100 101// str safely extracts a string field from a map[string]any. 102func str(m map[string]any, key string) string { 103 if v, ok := m[key]; ok { 104 if s, ok := v.(string); ok { 105 return s 106 } 107 } 108 return "" 109} 110 111func firstString(m map[string]any, keys ...string) string { 112 for _, key := range keys { 113 if v := str(m, key); v != "" { 114 return v 115 } 116 } 117 return "" 118} 119 120// nestedMap safely extracts a nested map[string]any from a map. 121func nestedMap(m map[string]any, key string) map[string]any { 122 if v, ok := m[key]; ok { 123 if nested, ok := v.(map[string]any); ok { 124 return nested 125 } 126 } 127 return nil 128}