cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat: list pubs

+146 -12
+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 1 + // Package services provides AT Protocol integration for leaflet.pub 7 2 // 8 - // TODO: Implement publication listing: 9 - // 1. Query records with collection: pub.leaflet.publication 10 - // 2. Parse as public.Publication 11 - // 3. Return list 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 12 7 // 13 - // TODO: Implement session clearing: close any open connections 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 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 - return nil, fmt.Errorf("publication listing not yet implemented") 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 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 + 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 + }) 437 502 }) 438 503 439 504 t.Run("Authentication Error Scenarios", func(t *testing.T) {