tangled
alpha
login
or
join now
desertthunder.dev
/
noteleaf
cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐
charm
leaflet
readability
golang
29
fork
atom
overview
issues
2
pulls
pipelines
feat: list pubs
desertthunder.dev
3 months ago
ea81ef53
367cade3
+146
-12
2 changed files
expand all
collapse all
unified
split
internal
services
atproto.go
atproto_test.go
+81
-12
internal/services/atproto.go
···
1
-
// TODO: Implement document fetching:
2
-
// 1. Call com.atproto.sync.getRepo to get repository CAR file
3
-
// 2. Parse CAR (Content Addressable aRchive) format
4
-
// 3. Filter records by collection: pub.leaflet.document
5
-
// 4. Extract documents and metadata
6
-
// 5. Return as []DocumentWithMeta
7
//
8
-
// TODO: Implement publication listing:
9
-
// 1. Query records with collection: pub.leaflet.publication
10
-
// 2. Parse as public.Publication
11
-
// 3. Return list
12
//
13
-
// TODO: Implement session clearing: close any open connections
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
14
package services
15
16
import (
···
250
if !s.IsAuthenticated() {
251
return nil, fmt.Errorf("not authenticated")
252
}
253
-
return nil, fmt.Errorf("publication listing not yet implemented")
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
254
}
255
256
// Close cleans up resources
···
1
+
// Package services provides AT Protocol integration for leaflet.pub
0
0
0
0
0
2
//
3
+
// Document Flow:
4
+
// - Pull: Fetch pub.leaflet.document records from AT Protocol repository
5
+
// - Post: Create new pub.leaflet.document records in AT Protocol repository
6
+
// - Push: Update existing pub.leaflet.document records in AT Protocol repository
7
//
8
+
// Publishing Workflow (TODO):
9
+
// 1. Post - Create new document:
10
+
// - Convert note to pub.leaflet.document format
11
+
// - Upload any embedded images as blobs
12
+
// - Create record with com.atproto.repo.createRecord
13
+
// - Store returned rkey and cid in note metadata
14
+
// 2. Push - Update existing document:
15
+
// - Check if note has leaflet_rkey (indicates previously published)
16
+
// - Convert updated note to pub.leaflet.document format
17
+
// - Upload any new images as blobs
18
+
// - Update record with com.atproto.repo.putRecord
19
+
// - Update stored cid in note metadata
20
+
// 3. Delete - Remove published document:
21
+
// - Use com.atproto.repo.deleteRecord with stored rkey
22
+
// - Clear leaflet metadata from note
23
+
//
24
+
// Blob Upload (TODO):
25
+
// 1. Use com.atproto.repo.uploadBlob for images
26
+
// 2. Returns blob reference with CID
27
+
// 3. Include blob ref in ImageBlock structures
28
+
//
29
+
// Draft vs Published (TODO):
30
+
// 1. Draft documents stored in collection: pub.leaflet.document.draft
31
+
// 2. Published documents in: pub.leaflet.document
32
+
// 3. Moving from draft to published requires:
33
+
// - Delete from draft collection
34
+
// - Create in published collection
35
+
// - Update note metadata with new rkey
36
package services
37
38
import (
···
272
if !s.IsAuthenticated() {
273
return nil, fmt.Errorf("not authenticated")
274
}
275
+
276
+
carBytes, err := atproto.SyncGetRepo(ctx, s.client, s.session.DID, "")
277
+
if err != nil {
278
+
return nil, fmt.Errorf("failed to fetch repository: %w", err)
279
+
}
280
+
281
+
r, err := repo.ReadRepoFromCar(ctx, bytes.NewReader(carBytes))
282
+
if err != nil {
283
+
return nil, fmt.Errorf("failed to parse CAR file: %w", err)
284
+
}
285
+
286
+
var publications []PublicationWithMeta
287
+
prefix := public.TypePublication
288
+
289
+
err = r.ForEach(ctx, prefix, func(k string, v cid.Cid) error {
290
+
_, recordBytes, err := r.GetRecordBytes(ctx, k)
291
+
if err != nil {
292
+
return fmt.Errorf("failed to get record bytes for %s: %w", k, err)
293
+
}
294
+
295
+
var pub public.Publication
296
+
if err := json.Unmarshal(*recordBytes, &pub); err != nil {
297
+
return fmt.Errorf("failed to unmarshal publication %s: %w", k, err)
298
+
}
299
+
300
+
parts := strings.Split(k, "/")
301
+
rkey := ""
302
+
if len(parts) > 0 {
303
+
rkey = parts[len(parts)-1]
304
+
}
305
+
306
+
uri := fmt.Sprintf("at://%s/%s", s.session.DID, k)
307
+
308
+
publications = append(publications, PublicationWithMeta{
309
+
Publication: pub,
310
+
RKey: rkey,
311
+
CID: v.String(),
312
+
URI: uri,
313
+
})
314
+
315
+
return nil
316
+
})
317
+
318
+
if err != nil {
319
+
return nil, fmt.Errorf("failed to iterate over publications: %w", err)
320
+
}
321
+
322
+
return publications, nil
323
}
324
325
// Close cleans up resources
+65
internal/services/atproto_test.go
···
434
t.Error("Expected nil publications when session not authenticated")
435
}
436
})
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
437
})
438
439
t.Run("Authentication Error Scenarios", func(t *testing.T) {
···
434
t.Error("Expected nil publications when session not authenticated")
435
}
436
})
437
+
438
+
t.Run("returns error when context cancelled", func(t *testing.T) {
439
+
svc := NewATProtoService()
440
+
svc.session = &Session{
441
+
DID: "did:plc:test123",
442
+
Handle: "test.bsky.social",
443
+
AccessJWT: "access_token",
444
+
RefreshJWT: "refresh_token",
445
+
Authenticated: true,
446
+
}
447
+
448
+
ctx, cancel := context.WithCancel(context.Background())
449
+
cancel()
450
+
451
+
pubs, err := svc.ListPublications(ctx)
452
+
if err == nil {
453
+
t.Error("Expected error when context is cancelled")
454
+
}
455
+
if pubs != nil {
456
+
t.Error("Expected nil publications when context is cancelled")
457
+
}
458
+
})
459
+
460
+
t.Run("returns error when context timeout", func(t *testing.T) {
461
+
svc := NewATProtoService()
462
+
svc.session = &Session{
463
+
DID: "did:plc:test123",
464
+
Handle: "test.bsky.social",
465
+
AccessJWT: "access_token",
466
+
RefreshJWT: "refresh_token",
467
+
Authenticated: true,
468
+
}
469
+
470
+
ctx, cancel := context.WithTimeout(context.Background(), 1)
471
+
defer cancel()
472
+
time.Sleep(2 * time.Millisecond)
473
+
474
+
pubs, err := svc.ListPublications(ctx)
475
+
if err == nil {
476
+
t.Error("Expected error when context times out")
477
+
}
478
+
if pubs != nil {
479
+
t.Error("Expected nil publications when context times out")
480
+
}
481
+
})
482
+
483
+
t.Run("returns empty list when no publications exist", func(t *testing.T) {
484
+
svc := NewATProtoService()
485
+
svc.session = &Session{
486
+
DID: "did:plc:test123",
487
+
Handle: "test.bsky.social",
488
+
AccessJWT: "access_token",
489
+
RefreshJWT: "refresh_token",
490
+
Authenticated: true,
491
+
}
492
+
ctx := context.Background()
493
+
494
+
pubs, err := svc.ListPublications(ctx)
495
+
496
+
if err != nil && err.Error() == "not authenticated" {
497
+
t.Error("Authentication check should pass, but got authentication error")
498
+
}
499
+
500
+
_ = pubs
501
+
})
502
})
503
504
t.Run("Authentication Error Scenarios", func(t *testing.T) {