a love letter to tangled (android, iOS, and a search API)
1package backfill
2
3import (
4 "context"
5 "errors"
6 "fmt"
7
8 "tangled.org/desertthunder.dev/twister/internal/normalize"
9 "tangled.org/desertthunder.dev/twister/internal/store"
10 "tangled.org/desertthunder.dev/twister/internal/xrpc"
11)
12
13const profileCollection = "sh.tangled.actor.profile"
14const repoCollection = "sh.tangled.repo"
15
16// ProfileRecord holds the fetched profile data and resolved handle.
17type ProfileRecord struct {
18 Record map[string]any
19 CID string
20 Handle string
21}
22
23type profileFetcher interface {
24 FetchProfile(ctx context.Context, did string) (*ProfileRecord, error)
25}
26
27type RepoRecord struct {
28 RKey string
29 CID string
30 Record map[string]any
31}
32
33type repoFetcher interface {
34 ListRepos(ctx context.Context, did string) ([]RepoRecord, error)
35}
36
37// XRPCProfileFetcher fetches sh.tangled.actor.profile records via xrpc.Client
38// and resolves handles from the DID document.
39type XRPCProfileFetcher struct {
40 client *xrpc.Client
41}
42
43func NewXRPCProfileFetcher(client *xrpc.Client) *XRPCProfileFetcher {
44 return &XRPCProfileFetcher{client: client}
45}
46
47func (f *XRPCProfileFetcher) FetchProfile(ctx context.Context, did string) (*ProfileRecord, error) {
48 info, err := f.client.ResolveIdentity(ctx, did)
49 if err != nil {
50 return nil, fmt.Errorf("resolve identity: %w", err)
51 }
52
53 rec, err := f.client.GetRecord(ctx, info.PDS, did, profileCollection, "self")
54 if err != nil {
55 var nfe *xrpc.NotFoundError
56 if errors.As(err, &nfe) {
57 return &ProfileRecord{Handle: info.Handle}, nil
58 }
59 return nil, fmt.Errorf("getRecord: %w", err)
60 }
61
62 return &ProfileRecord{
63 Record: rec.Value,
64 CID: rec.CID,
65 Handle: info.Handle,
66 }, nil
67}
68
69type XRPCRepoFetcher struct {
70 client *xrpc.Client
71}
72
73func NewXRPCRepoFetcher(client *xrpc.Client) *XRPCRepoFetcher {
74 return &XRPCRepoFetcher{client: client}
75}
76
77func (f *XRPCRepoFetcher) ListRepos(ctx context.Context, did string) ([]RepoRecord, error) {
78 info, err := f.client.ResolveIdentity(ctx, did)
79 if err != nil {
80 return nil, fmt.Errorf("resolve identity: %w", err)
81 }
82
83 records, err := f.client.ListAllRecords(ctx, info.PDS, did, repoCollection)
84 if err != nil {
85 return nil, fmt.Errorf("list repos: %w", err)
86 }
87
88 repos := make([]RepoRecord, 0, len(records))
89 for _, rec := range records {
90 _, _, rkey, err := normalize.ParseATURI(rec.URI)
91 if err != nil {
92 continue
93 }
94 repos = append(repos, RepoRecord{
95 RKey: rkey,
96 CID: rec.CID,
97 Record: rec.Value,
98 })
99 }
100 return repos, nil
101}
102
103func bootstrapProfileDocument(did string, profile *ProfileRecord, handle string) *store.Document {
104 if profile == nil || profile.Record == nil {
105 return nil
106 }
107
108 description, _ := profile.Record["description"].(string)
109 location, _ := profile.Record["location"].(string)
110 summary := description
111 if location != "" {
112 if summary != "" {
113 summary = summary + " · " + location
114 } else {
115 summary = location
116 }
117 }
118 if len(summary) > 200 {
119 summary = summary[:200]
120 }
121
122 return &store.Document{
123 ID: fmt.Sprintf("%s|%s|self", did, profileCollection),
124 DID: did,
125 Collection: profileCollection,
126 RKey: "self",
127 ATURI: fmt.Sprintf("at://%s/%s/self", did, profileCollection),
128 CID: profile.CID,
129 RecordType: "profile",
130 Title: handle,
131 Body: description,
132 Summary: summary,
133 AuthorHandle: handle,
134 TagsJSON: "[]",
135 }
136}
137
138func bootstrapRepoDocument(did, handle string, repo RepoRecord) *store.Document {
139 adapter := &normalize.RepoAdapter{}
140 if !adapter.Searchable(repo.Record) {
141 return nil
142 }
143
144 event := normalize.TapRecordEvent{
145 Type: "record",
146 Record: &normalize.TapRecord{
147 DID: did,
148 Collection: repoCollection,
149 RKey: repo.RKey,
150 CID: repo.CID,
151 Record: repo.Record,
152 },
153 }
154
155 doc, err := adapter.Normalize(event)
156 if err != nil {
157 return nil
158 }
159 doc.AuthorHandle = handle
160 doc.WebURL = xrpc.BuildWebURL(handle, doc.RepoName, doc.RecordType, doc.RKey)
161 return doc
162}