a love letter to tangled (android, iOS, and a search API)
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}