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