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

feat(wip): push to leaflet!

+675 -149
+8 -5
cmd/commands.go
··· 30 Short: "Manage movie watch queue", 31 Long: `Track movies you want to watch. 32 33 - Search TMDB for movies and add them to your queue. Mark movies as watched when 34 - completed. Maintains a history of your movie watching activity.`, 35 } 36 37 addCmd := &cobra.Command{ 38 Use: "add [search query...]", 39 Short: "Search and add movie to watch queue", ··· 54 addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for movie selection") 55 root.AddCommand(addCmd) 56 57 root.AddCommand(&cobra.Command{ 58 Use: "list [--all|--watched|--queued]", 59 Short: "List movies in queue with status filtering", ··· 122 Short: "Manage TV show watch queue", 123 Long: `Track TV shows and episodes. 124 125 - Search TMDB for TV shows and add them to your queue. Track which shows you're 126 - currently watching, mark episodes as watched, and maintain a complete history 127 - of your viewing activity.`, 128 } 129 130 addCmd := &cobra.Command{
··· 30 Short: "Manage movie watch queue", 31 Long: `Track movies you want to watch. 32 33 + Search for movies and add them to your queue. Mark movies as watched 34 + when completed. Maintains a history of your movie watching activity.`, 35 } 36 37 + // TODO: add colors 38 + // TODO: fix critic score parsing 39 addCmd := &cobra.Command{ 40 Use: "add [search query...]", 41 Short: "Search and add movie to watch queue", ··· 56 addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for movie selection") 57 root.AddCommand(addCmd) 58 59 + // TODO: add interactive list view 60 root.AddCommand(&cobra.Command{ 61 Use: "list [--all|--watched|--queued]", 62 Short: "List movies in queue with status filtering", ··· 125 Short: "Manage TV show watch queue", 126 Long: `Track TV shows and episodes. 127 128 + Search for TV shows and add them to your queue. Track which shows you're currently 129 + watching, mark episodes as watched, and maintain a complete history of your viewing 130 + activity.`, 131 } 132 133 addCmd := &cobra.Command{
+8
cmd/publication_commands.go
··· 59 handle = args[0] 60 } 61 62 password, _ := cmd.Flags().GetString("password") 63 64 if handle != "" && password != "" {
··· 59 handle = args[0] 60 } 61 62 + // Auto-fill with last authenticated handle if available 63 + if handle == "" { 64 + lastHandle := c.handler.GetLastAuthenticatedHandle() 65 + if lastHandle != "" { 66 + handle = lastHandle 67 + } 68 + } 69 + 70 password, _ := cmd.Flags().GetString("password") 71 72 if handle != "" && password != "" {
+36 -2
internal/handlers/publication.go
··· 10 "strings" 11 "time" 12 13 "github.com/stormlightlabs/noteleaf/internal/models" 14 "github.com/stormlightlabs/noteleaf/internal/public" 15 "github.com/stormlightlabs/noteleaf/internal/repo" 16 "github.com/stormlightlabs/noteleaf/internal/services" 17 "github.com/stormlightlabs/noteleaf/internal/store" 18 "github.com/stormlightlabs/noteleaf/internal/ui" 19 ) ··· 24 config *store.Config 25 repos *repo.Repositories 26 atproto services.ATProtoClient 27 } 28 29 // NewPublicationHandler creates a new publication handler ··· 41 repos := repo.NewRepositories(db.DB) 42 atproto := services.NewATProtoService() 43 44 if config.ATProtoDID != "" && config.ATProtoAccessJWT != "" && config.ATProtoRefreshJWT != "" { 45 session, err := sessionFromConfig(config) 46 if err == nil { 47 - _ = atproto.RestoreSession(session) 48 } 49 } 50 ··· 53 config: config, 54 repos: repos, 55 atproto: atproto, 56 }, nil 57 } 58 ··· 272 if err != nil { 273 return err 274 } 275 - 276 ui.Infoln("Creating document '%s' on leaflet...", note.Title) 277 278 result, err := h.atproto.PostDocument(ctx, *doc, isDraft) ··· 665 return nil, nil, fmt.Errorf("failed to convert markdown to leaflet format: %w", err) 666 } 667 668 doc := &public.Document{ 669 Author: session.DID, 670 Title: note.Title, 671 Description: "", 672 Pages: []public.LinearDocument{ 673 { 674 Type: public.TypeLinearDocument, ··· 901 return "Authenticated (session details unavailable)" 902 } 903 return "Not authenticated" 904 } 905 906 // extractNoteDirectory extracts the directory path from a note's FilePath
··· 10 "strings" 11 "time" 12 13 + "github.com/charmbracelet/log" 14 "github.com/stormlightlabs/noteleaf/internal/models" 15 "github.com/stormlightlabs/noteleaf/internal/public" 16 "github.com/stormlightlabs/noteleaf/internal/repo" 17 "github.com/stormlightlabs/noteleaf/internal/services" 18 + "github.com/stormlightlabs/noteleaf/internal/shared" 19 "github.com/stormlightlabs/noteleaf/internal/store" 20 "github.com/stormlightlabs/noteleaf/internal/ui" 21 ) ··· 26 config *store.Config 27 repos *repo.Repositories 28 atproto services.ATProtoClient 29 + debug *log.Logger 30 } 31 32 // NewPublicationHandler creates a new publication handler ··· 44 repos := repo.NewRepositories(db.DB) 45 atproto := services.NewATProtoService() 46 47 + d, _ := store.GetConfigDir() 48 + debug := shared.NewDebugLoggerWithFile(d) 49 + 50 if config.ATProtoDID != "" && config.ATProtoAccessJWT != "" && config.ATProtoRefreshJWT != "" { 51 session, err := sessionFromConfig(config) 52 if err == nil { 53 + if err := atproto.RestoreSession(session); err == nil { 54 + updatedSession, _ := atproto.GetSession() 55 + if updatedSession != nil { 56 + config.ATProtoAccessJWT = updatedSession.AccessJWT 57 + config.ATProtoRefreshJWT = updatedSession.RefreshJWT 58 + config.ATProtoExpiresAt = updatedSession.ExpiresAt.Format("2006-01-02T15:04:05Z07:00") 59 + _ = store.SaveConfig(config) 60 + } 61 + } 62 } 63 } 64 ··· 67 config: config, 68 repos: repos, 69 atproto: atproto, 70 + debug: debug, 71 }, nil 72 } 73 ··· 287 if err != nil { 288 return err 289 } 290 ui.Infoln("Creating document '%s' on leaflet...", note.Title) 291 292 result, err := h.atproto.PostDocument(ctx, *doc, isDraft) ··· 679 return nil, nil, fmt.Errorf("failed to convert markdown to leaflet format: %w", err) 680 } 681 682 + publicationURI, err := h.atproto.GetDefaultPublication(ctx) 683 + if err != nil { 684 + return nil, nil, fmt.Errorf("failed to get publication: %w", err) 685 + } 686 + 687 + docType := public.TypeDocument 688 + if isDraft { 689 + docType = public.TypeDocumentDraft 690 + } 691 + 692 doc := &public.Document{ 693 + Type: docType, 694 Author: session.DID, 695 Title: note.Title, 696 Description: "", 697 + Publication: publicationURI, 698 Pages: []public.LinearDocument{ 699 { 700 Type: public.TypeLinearDocument, ··· 927 return "Authenticated (session details unavailable)" 928 } 929 return "Not authenticated" 930 + } 931 + 932 + // GetLastAuthenticatedHandle returns the last authenticated handle from config 933 + func (h *PublicationHandler) GetLastAuthenticatedHandle() string { 934 + if h.config != nil && h.config.ATProtoHandle != "" { 935 + return h.config.ATProtoHandle 936 + } 937 + return "" 938 } 939 940 // extractNoteDirectory extracts the directory path from a note's FilePath
+77 -30
internal/handlers/publication_test.go
··· 189 }) 190 }) 191 192 t.Run("NewPublicationHandler", func(t *testing.T) { 193 t.Run("creates handler successfully", func(t *testing.T) { 194 suite := NewHandlerTestSuite(t) ··· 1120 id, err := handler.repos.Notes.Create(ctx, note) 1121 suite.AssertNoError(err, "create note") 1122 1123 - session := &services.Session{ 1124 DID: "did:plc:test123", 1125 Handle: "test.bsky.social", 1126 AccessJWT: "access_token", ··· 1129 ExpiresAt: time.Now().Add(2 * time.Hour), 1130 Authenticated: true, 1131 } 1132 - 1133 - err = handler.atproto.RestoreSession(session) 1134 - if err != nil { 1135 - t.Fatalf("Failed to restore session: %v", err) 1136 - } 1137 1138 err = handler.PostPreview(ctx, id, false, "", false) 1139 suite.AssertNoError(err, "preview should succeed") ··· 1154 id, err := handler.repos.Notes.Create(ctx, note) 1155 suite.AssertNoError(err, "create note") 1156 1157 - session := &services.Session{ 1158 DID: "did:plc:test123", 1159 Handle: "test.bsky.social", 1160 AccessJWT: "access_token", ··· 1163 ExpiresAt: time.Now().Add(2 * time.Hour), 1164 Authenticated: true, 1165 } 1166 - 1167 - err = handler.atproto.RestoreSession(session) 1168 - if err != nil { 1169 - t.Fatalf("Failed to restore session: %v", err) 1170 - } 1171 1172 err = handler.PostPreview(ctx, id, true, "", false) 1173 suite.AssertNoError(err, "preview draft should succeed") ··· 1207 id, err := handler.repos.Notes.Create(ctx, note) 1208 suite.AssertNoError(err, "create note") 1209 1210 - session := &services.Session{ 1211 DID: "did:plc:test123", 1212 Handle: "test.bsky.social", 1213 AccessJWT: "access_token", ··· 1216 ExpiresAt: time.Now().Add(2 * time.Hour), 1217 Authenticated: true, 1218 } 1219 - 1220 - err = handler.atproto.RestoreSession(session) 1221 - if err != nil { 1222 - t.Fatalf("Failed to restore session: %v", err) 1223 - } 1224 1225 err = handler.PostValidate(ctx, id, false, "", false) 1226 suite.AssertNoError(err, "validation should succeed") ··· 1339 id, err := handler.repos.Notes.Create(ctx, note) 1340 suite.AssertNoError(err, "create note") 1341 1342 - session := &services.Session{ 1343 DID: "did:plc:test123", 1344 Handle: "test.bsky.social", 1345 AccessJWT: "access_token", ··· 1348 ExpiresAt: time.Now().Add(2 * time.Hour), 1349 Authenticated: true, 1350 } 1351 - 1352 - err = handler.atproto.RestoreSession(session) 1353 - if err != nil { 1354 - t.Fatalf("Failed to restore session: %v", err) 1355 - } 1356 1357 err = handler.PatchPreview(ctx, id, "", false) 1358 suite.AssertNoError(err, "preview should succeed") ··· 1397 id, err := handler.repos.Notes.Create(ctx, note) 1398 suite.AssertNoError(err, "create note") 1399 1400 - session := &services.Session{ 1401 DID: "did:plc:test123", 1402 Handle: "test.bsky.social", 1403 AccessJWT: "access_token", ··· 1406 ExpiresAt: time.Now().Add(2 * time.Hour), 1407 Authenticated: true, 1408 } 1409 - 1410 - err = handler.atproto.RestoreSession(session) 1411 - if err != nil { 1412 - t.Fatalf("Failed to restore session: %v", err) 1413 - } 1414 1415 err = handler.PatchValidate(ctx, id, "", false) 1416 suite.AssertNoError(err, "validation should succeed")
··· 189 }) 190 }) 191 192 + t.Run("GetLastAuthenticatedHandle", func(t *testing.T) { 193 + t.Run("returns empty string when no config", func(t *testing.T) { 194 + handler := &PublicationHandler{ 195 + config: nil, 196 + } 197 + 198 + handle := handler.GetLastAuthenticatedHandle() 199 + if handle != "" { 200 + t.Errorf("Expected empty string, got '%s'", handle) 201 + } 202 + }) 203 + 204 + t.Run("returns empty string when handle not set", func(t *testing.T) { 205 + handler := &PublicationHandler{ 206 + config: &store.Config{}, 207 + } 208 + 209 + handle := handler.GetLastAuthenticatedHandle() 210 + if handle != "" { 211 + t.Errorf("Expected empty string, got '%s'", handle) 212 + } 213 + }) 214 + 215 + t.Run("returns handle from config", func(t *testing.T) { 216 + expectedHandle := "test.bsky.social" 217 + handler := &PublicationHandler{ 218 + config: &store.Config{ 219 + ATProtoHandle: expectedHandle, 220 + }, 221 + } 222 + 223 + handle := handler.GetLastAuthenticatedHandle() 224 + if handle != expectedHandle { 225 + t.Errorf("Expected '%s', got '%s'", expectedHandle, handle) 226 + } 227 + }) 228 + 229 + t.Run("returns handle after successful authentication", func(t *testing.T) { 230 + suite := NewHandlerTestSuite(t) 231 + defer suite.Cleanup() 232 + 233 + handler := CreateHandler(t, NewPublicationHandler) 234 + ctx := context.Background() 235 + 236 + mock := services.SetupSuccessfulAuthMocks() 237 + handler.atproto = mock 238 + 239 + err := handler.Auth(ctx, "user.bsky.social", "password123") 240 + suite.AssertNoError(err, "authentication should succeed") 241 + 242 + handle := handler.GetLastAuthenticatedHandle() 243 + if handle != "user.bsky.social" { 244 + t.Errorf("Expected 'user.bsky.social', got '%s'", handle) 245 + } 246 + }) 247 + }) 248 + 249 t.Run("NewPublicationHandler", func(t *testing.T) { 250 t.Run("creates handler successfully", func(t *testing.T) { 251 suite := NewHandlerTestSuite(t) ··· 1177 id, err := handler.repos.Notes.Create(ctx, note) 1178 suite.AssertNoError(err, "create note") 1179 1180 + mock := services.NewMockATProtoService() 1181 + mock.IsAuthenticatedVal = true 1182 + mock.Session = &services.Session{ 1183 DID: "did:plc:test123", 1184 Handle: "test.bsky.social", 1185 AccessJWT: "access_token", ··· 1188 ExpiresAt: time.Now().Add(2 * time.Hour), 1189 Authenticated: true, 1190 } 1191 + handler.atproto = mock 1192 1193 err = handler.PostPreview(ctx, id, false, "", false) 1194 suite.AssertNoError(err, "preview should succeed") ··· 1209 id, err := handler.repos.Notes.Create(ctx, note) 1210 suite.AssertNoError(err, "create note") 1211 1212 + mock := services.NewMockATProtoService() 1213 + mock.IsAuthenticatedVal = true 1214 + mock.Session = &services.Session{ 1215 DID: "did:plc:test123", 1216 Handle: "test.bsky.social", 1217 AccessJWT: "access_token", ··· 1220 ExpiresAt: time.Now().Add(2 * time.Hour), 1221 Authenticated: true, 1222 } 1223 + handler.atproto = mock 1224 1225 err = handler.PostPreview(ctx, id, true, "", false) 1226 suite.AssertNoError(err, "preview draft should succeed") ··· 1260 id, err := handler.repos.Notes.Create(ctx, note) 1261 suite.AssertNoError(err, "create note") 1262 1263 + mock := services.NewMockATProtoService() 1264 + mock.IsAuthenticatedVal = true 1265 + mock.Session = &services.Session{ 1266 DID: "did:plc:test123", 1267 Handle: "test.bsky.social", 1268 AccessJWT: "access_token", ··· 1271 ExpiresAt: time.Now().Add(2 * time.Hour), 1272 Authenticated: true, 1273 } 1274 + handler.atproto = mock 1275 1276 err = handler.PostValidate(ctx, id, false, "", false) 1277 suite.AssertNoError(err, "validation should succeed") ··· 1390 id, err := handler.repos.Notes.Create(ctx, note) 1391 suite.AssertNoError(err, "create note") 1392 1393 + mock := services.NewMockATProtoService() 1394 + mock.IsAuthenticatedVal = true 1395 + mock.Session = &services.Session{ 1396 DID: "did:plc:test123", 1397 Handle: "test.bsky.social", 1398 AccessJWT: "access_token", ··· 1401 ExpiresAt: time.Now().Add(2 * time.Hour), 1402 Authenticated: true, 1403 } 1404 + handler.atproto = mock 1405 1406 err = handler.PatchPreview(ctx, id, "", false) 1407 suite.AssertNoError(err, "preview should succeed") ··· 1446 id, err := handler.repos.Notes.Create(ctx, note) 1447 suite.AssertNoError(err, "create note") 1448 1449 + mock := services.NewMockATProtoService() 1450 + mock.IsAuthenticatedVal = true 1451 + mock.Session = &services.Session{ 1452 DID: "did:plc:test123", 1453 Handle: "test.bsky.social", 1454 AccessJWT: "access_token", ··· 1457 ExpiresAt: time.Now().Add(2 * time.Hour), 1458 Authenticated: true, 1459 } 1460 + handler.atproto = mock 1461 1462 err = handler.PatchValidate(ctx, id, "", false) 1463 suite.AssertNoError(err, "validation should succeed")
+90 -31
internal/services/atproto.go
··· 117 PatchDocument(ctx context.Context, rkey string, doc public.Document, isDraft bool) (*DocumentWithMeta, error) 118 DeleteDocument(ctx context.Context, rkey string, isDraft bool) error 119 UploadBlob(ctx context.Context, data []byte, mimeType string) (public.Blob, error) 120 Close() error 121 } 122 ··· 127 session *Session 128 pdsURL string // Personal Data Server URL 129 client *xrpc.Client 130 } 131 132 // NewATProtoService creates a new AT Protocol service ··· 195 } 196 197 // RestoreSession restores a previously authenticated session from stored credentials 198 func (s *ATProtoService) RestoreSession(session *Session) error { 199 if session == nil { 200 return fmt.Errorf("session cannot be nil") ··· 218 s.client.Host = session.PDSURL 219 } 220 221 return nil 222 } 223 224 // RefreshToken refreshes the access token using the refresh token 225 func (s *ATProtoService) RefreshToken(ctx context.Context) error { 226 if s.session == nil || s.session.RefreshJWT == "" { 227 return fmt.Errorf("no session available to refresh") ··· 239 return fmt.Errorf("failed to refresh session: %w", err) 240 } 241 242 expiresAt := time.Now().Add(2 * time.Hour) 243 s.session.AccessJWT = output.AccessJwt 244 s.session.RefreshJWT = output.RefreshJwt ··· 362 return fmt.Errorf("failed to get record bytes for %s: %w", k, err) 363 } 364 365 - var pub public.Publication 366 - if err := json.Unmarshal(*recordBytes, &pub); err != nil { 367 - return fmt.Errorf("failed to unmarshal publication %s: %w", k, err) 368 } 369 370 parts := strings.Split(k, "/") 371 rkey := "" 372 if len(parts) > 0 { 373 rkey = parts[len(parts)-1] 374 } 375 376 uri := fmt.Sprintf("at://%s/%s", s.session.DID, k) ··· 389 return publications, nil 390 } 391 392 // PostDocument creates a new document in the user's repository 393 func (s *ATProtoService) PostDocument(ctx context.Context, doc public.Document, isDraft bool) (*DocumentWithMeta, error) { 394 if !s.IsAuthenticated() { ··· 405 } 406 407 doc.Type = collection 408 - 409 jsonBytes, err := json.Marshal(doc) 410 if err != nil { 411 - return nil, fmt.Errorf("failed to marshal document to JSON: %w", err) 412 } 413 414 - var jsonData map[string]any 415 - if err := json.Unmarshal(jsonBytes, &jsonData); err != nil { 416 - return nil, fmt.Errorf("failed to unmarshal JSON to map: %w", err) 417 } 418 419 - cborCompatible := convertJSONToCBORCompatible(jsonData) 420 - 421 - cborBytes, err := cbor.Marshal(cborCompatible) 422 - if err != nil { 423 - return nil, fmt.Errorf("failed to marshal to CBOR: %w", err) 424 - } 425 - 426 - record := &lexutil.LexiconTypeDecoder{} 427 - if err := cbor.Unmarshal(cborBytes, record); err != nil { 428 - return nil, fmt.Errorf("failed to unmarshal CBOR to lexicon type: %w", err) 429 - } 430 - 431 - input := &atproto.RepoCreateRecord_Input{ 432 - Repo: s.session.DID, 433 - Collection: collection, 434 - Record: record, 435 - } 436 - 437 - output, err := atproto.RepoCreateRecord(ctx, s.client, input) 438 if err != nil { 439 return nil, fmt.Errorf("failed to create record: %w", err) 440 } 441 442 parts := strings.Split(output.Uri, "/") 443 - rkey := "" 444 - if len(parts) > 0 { 445 - rkey = parts[len(parts)-1] 446 - } 447 448 meta := public.DocumentMeta{ 449 RKey: rkey, ··· 592 s.session = nil 593 return nil 594 }
··· 117 PatchDocument(ctx context.Context, rkey string, doc public.Document, isDraft bool) (*DocumentWithMeta, error) 118 DeleteDocument(ctx context.Context, rkey string, isDraft bool) error 119 UploadBlob(ctx context.Context, data []byte, mimeType string) (public.Blob, error) 120 + GetDefaultPublication(ctx context.Context) (string, error) 121 Close() error 122 } 123 ··· 128 session *Session 129 pdsURL string // Personal Data Server URL 130 client *xrpc.Client 131 + 132 + // TODO: Future enhancement - integrate OS keychain for secure password storage 133 + // Consider using keyring libraries like: 134 + // - github.com/zalando/go-keyring (cross-platform) 135 + // - keychain access on macOS (Security.framework) 136 + // - Windows Credential Manager (credman) 137 + // - Linux Secret Service API (libsecret) 138 + // This would allow storing app passwords securely in the system keychain 139 + // instead of requiring re-authentication every time JWTs expire. 140 } 141 142 // NewATProtoService creates a new AT Protocol service ··· 205 } 206 207 // RestoreSession restores a previously authenticated session from stored credentials 208 + // and automatically refreshes the token if expired 209 func (s *ATProtoService) RestoreSession(session *Session) error { 210 if session == nil { 211 return fmt.Errorf("session cannot be nil") ··· 229 s.client.Host = session.PDSURL 230 } 231 232 + // Check if token is expired or about to expire (within 5 minutes) 233 + if time.Now().Add(5 * time.Minute).After(session.ExpiresAt) { 234 + ctx := context.Background() 235 + if err := s.RefreshToken(ctx); err != nil { 236 + // Token refresh failed - session may be invalid 237 + // User will need to re-authenticate 238 + return fmt.Errorf("session expired and refresh failed: %w", err) 239 + } 240 + } 241 + 242 return nil 243 } 244 245 // RefreshToken refreshes the access token using the refresh token 246 + // This extends the session without requiring the user to re-authenticate 247 func (s *ATProtoService) RefreshToken(ctx context.Context) error { 248 if s.session == nil || s.session.RefreshJWT == "" { 249 return fmt.Errorf("no session available to refresh") ··· 261 return fmt.Errorf("failed to refresh session: %w", err) 262 } 263 264 + // TODO: Consider increasing token lifetime for better UX 265 + // Current: 2 hours - requires frequent re-authentication 266 + // Consider: Store in OS keychain to enable longer sessions without security risk 267 expiresAt := time.Now().Add(2 * time.Hour) 268 s.session.AccessJWT = output.AccessJwt 269 s.session.RefreshJWT = output.RefreshJwt ··· 387 return fmt.Errorf("failed to get record bytes for %s: %w", k, err) 388 } 389 390 + var cborData any 391 + if err := cbor.Unmarshal(*recordBytes, &cborData); err != nil { 392 + return fmt.Errorf("failed to decode CBOR for document %s: %w", k, err) 393 + } 394 + 395 + jsonCompatible := convertCBORToJSONCompatible(cborData) 396 + 397 + jsonBytes, err := json.MarshalIndent(jsonCompatible, "", " ") 398 + if err != nil { 399 + return fmt.Errorf("failed to convert CBOR to JSON for document %s: %w", k, err) 400 } 401 402 parts := strings.Split(k, "/") 403 rkey := "" 404 if len(parts) > 0 { 405 rkey = parts[len(parts)-1] 406 + } 407 + 408 + var pub public.Publication 409 + if err := json.Unmarshal(jsonBytes, &pub); err != nil { 410 + return fmt.Errorf("failed to unmarshal publication %s: %w", k, err) 411 } 412 413 uri := fmt.Sprintf("at://%s/%s", s.session.DID, k) ··· 426 return publications, nil 427 } 428 429 + // GetDefaultPublication returns the URI of the first available publication for the authenticated user 430 + // 431 + // Returns an error if no publications exist 432 + func (s *ATProtoService) GetDefaultPublication(ctx context.Context) (string, error) { 433 + publications, err := s.ListPublications(ctx) 434 + if err != nil { 435 + return "", err 436 + } 437 + 438 + if len(publications) == 0 { 439 + return "", fmt.Errorf("no publications found - create a publication on leaflet.pub first") 440 + } 441 + 442 + return publications[0].URI, nil 443 + } 444 + 445 // PostDocument creates a new document in the user's repository 446 func (s *ATProtoService) PostDocument(ctx context.Context, doc public.Document, isDraft bool) (*DocumentWithMeta, error) { 447 if !s.IsAuthenticated() { ··· 458 } 459 460 doc.Type = collection 461 jsonBytes, err := json.Marshal(doc) 462 if err != nil { 463 + return nil, fmt.Errorf("marshal: %w", err) 464 } 465 466 + var m map[string]any 467 + if err := json.Unmarshal(jsonBytes, &m); err != nil { 468 + return nil, fmt.Errorf("unmarshal: %w", err) 469 } 470 + m["$type"] = collection 471 472 + output, err := repoCreateRecord(ctx, s.client, s.session.DID, collection, m) 473 if err != nil { 474 return nil, fmt.Errorf("failed to create record: %w", err) 475 } 476 477 parts := strings.Split(output.Uri, "/") 478 + rkey := parts[len(parts)-1] 479 480 meta := public.DocumentMeta{ 481 RKey: rkey, ··· 624 s.session = nil 625 return nil 626 } 627 + 628 + type RepoCreateRecordOutput struct { 629 + Cid string `json:"cid"` 630 + Uri string `json:"uri"` 631 + } 632 + 633 + func repoCreateRecord(ctx context.Context, client *xrpc.Client, repo, collection string, record map[string]any) (*RepoCreateRecordOutput, error) { 634 + body := map[string]any{ 635 + "repo": repo, 636 + "collection": collection, 637 + "record": record, 638 + } 639 + 640 + var out RepoCreateRecordOutput 641 + if err := client.LexDo( 642 + ctx, 643 + lexutil.Procedure, 644 + "application/json", 645 + "com.atproto.repo.createRecord", 646 + nil, 647 + body, 648 + &out, 649 + ); err != nil { 650 + return nil, fmt.Errorf("repoCreateRecord failed: %w", err) 651 + } 652 + return &out, nil 653 + }
+88 -5
internal/services/atproto_test.go
··· 506 }) 507 }) 508 509 t.Run("Authentication Error Scenarios", func(t *testing.T) { 510 t.Run("returns error with context timeout", func(t *testing.T) { 511 svc := NewATProtoService() ··· 1153 defaultPDSURL := svc.pdsURL 1154 1155 session := &Session{ 1156 - DID: "did:plc:test123", 1157 - Handle: "test.bsky.social", 1158 - AccessJWT: "access_token", 1159 - RefreshJWT: "refresh_token", 1160 - PDSURL: "", 1161 } 1162 1163 err := svc.RestoreSession(session)
··· 506 }) 507 }) 508 509 + t.Run("GetDefaultPublication", func(t *testing.T) { 510 + t.Run("returns error when not authenticated", func(t *testing.T) { 511 + svc := NewATProtoService() 512 + ctx := context.Background() 513 + 514 + uri, err := svc.GetDefaultPublication(ctx) 515 + if err == nil { 516 + t.Error("Expected error when getting default publication without authentication") 517 + } 518 + if uri != "" { 519 + t.Errorf("Expected empty URI, got %s", uri) 520 + } 521 + if !strings.Contains(err.Error(), "not authenticated") { 522 + t.Errorf("Expected 'not authenticated' error, got '%v'", err) 523 + } 524 + }) 525 + 526 + t.Run("returns error when session not authenticated", func(t *testing.T) { 527 + svc := NewATProtoService() 528 + ctx := context.Background() 529 + svc.session = &Session{ 530 + Handle: "test.bsky.social", 531 + Authenticated: false, 532 + } 533 + 534 + uri, err := svc.GetDefaultPublication(ctx) 535 + if err == nil { 536 + t.Error("Expected error when getting default publication with unauthenticated session") 537 + } 538 + if uri != "" { 539 + t.Errorf("Expected empty URI, got %s", uri) 540 + } 541 + }) 542 + 543 + t.Run("returns error when no publications exist", func(t *testing.T) { 544 + svc := NewATProtoService() 545 + svc.session = &Session{ 546 + DID: "did:plc:test123", 547 + Handle: "test.bsky.social", 548 + AccessJWT: "access_token", 549 + RefreshJWT: "refresh_token", 550 + Authenticated: true, 551 + } 552 + ctx := context.Background() 553 + 554 + _, err := svc.GetDefaultPublication(ctx) 555 + if err == nil { 556 + t.Error("Expected error when getting default publication") 557 + } 558 + // With invalid credentials, we expect either auth error or no publications error 559 + if !strings.Contains(err.Error(), "no publications found") && 560 + !strings.Contains(err.Error(), "Authentication") && 561 + !strings.Contains(err.Error(), "AuthMissing") && 562 + !strings.Contains(err.Error(), "failed to fetch repository") { 563 + t.Errorf("Expected authentication or 'no publications found' error, got '%v'", err) 564 + } 565 + }) 566 + 567 + t.Run("returns error when context cancelled", func(t *testing.T) { 568 + svc := NewATProtoService() 569 + svc.session = &Session{ 570 + DID: "did:plc:test123", 571 + Handle: "test.bsky.social", 572 + AccessJWT: "access_token", 573 + RefreshJWT: "refresh_token", 574 + Authenticated: true, 575 + } 576 + 577 + ctx, cancel := context.WithCancel(context.Background()) 578 + cancel() 579 + 580 + uri, err := svc.GetDefaultPublication(ctx) 581 + if err == nil { 582 + t.Error("Expected error when context is cancelled") 583 + } 584 + if uri != "" { 585 + t.Errorf("Expected empty URI when error occurs, got %s", uri) 586 + } 587 + }) 588 + }) 589 + 590 t.Run("Authentication Error Scenarios", func(t *testing.T) { 591 t.Run("returns error with context timeout", func(t *testing.T) { 592 svc := NewATProtoService() ··· 1234 defaultPDSURL := svc.pdsURL 1235 1236 session := &Session{ 1237 + DID: "did:plc:test123", 1238 + Handle: "test.bsky.social", 1239 + AccessJWT: "access_token", 1240 + RefreshJWT: "refresh_token", 1241 + PDSURL: "", 1242 + ExpiresAt: time.Now().Add(2 * time.Hour), 1243 + Authenticated: true, 1244 } 1245 1246 err := svc.RestoreSession(session)
+25 -11
internal/services/test_utilities.go
··· 264 265 // MockATProtoService is a mock implementation of ATProtoService for testing 266 type MockATProtoService struct { 267 - AuthenticateFunc func(ctx context.Context, handle, password string) error 268 - GetSessionFunc func() (*Session, error) 269 - IsAuthenticatedVal bool 270 - RestoreSessionFunc func(session *Session) error 271 - PullDocumentsFunc func(ctx context.Context) ([]DocumentWithMeta, error) 272 - PostDocumentFunc func(ctx context.Context, doc public.Document, isDraft bool) (*DocumentWithMeta, error) 273 - PatchDocumentFunc func(ctx context.Context, rkey string, doc public.Document, isDraft bool) (*DocumentWithMeta, error) 274 - DeleteDocumentFunc func(ctx context.Context, rkey string, isDraft bool) error 275 - UploadBlobFunc func(ctx context.Context, data []byte, mimeType string) (public.Blob, error) 276 - CloseFunc func() error 277 - Session *Session // Exported for test access 278 } 279 280 // NewMockATProtoService creates a new mock AT Proto service ··· 395 MimeType: mimeType, 396 Size: len(data), 397 }, nil 398 } 399 400 // Close mocks cleanup
··· 264 265 // MockATProtoService is a mock implementation of ATProtoService for testing 266 type MockATProtoService struct { 267 + AuthenticateFunc func(ctx context.Context, handle, password string) error 268 + GetSessionFunc func() (*Session, error) 269 + IsAuthenticatedVal bool 270 + RestoreSessionFunc func(session *Session) error 271 + PullDocumentsFunc func(ctx context.Context) ([]DocumentWithMeta, error) 272 + PostDocumentFunc func(ctx context.Context, doc public.Document, isDraft bool) (*DocumentWithMeta, error) 273 + PatchDocumentFunc func(ctx context.Context, rkey string, doc public.Document, isDraft bool) (*DocumentWithMeta, error) 274 + DeleteDocumentFunc func(ctx context.Context, rkey string, isDraft bool) error 275 + UploadBlobFunc func(ctx context.Context, data []byte, mimeType string) (public.Blob, error) 276 + GetDefaultPublicationFunc func(ctx context.Context) (string, error) 277 + CloseFunc func() error 278 + Session *Session // Exported for test access 279 } 280 281 // NewMockATProtoService creates a new mock AT Proto service ··· 396 MimeType: mimeType, 397 Size: len(data), 398 }, nil 399 + } 400 + 401 + // GetDefaultPublication mocks getting the default publication 402 + func (m *MockATProtoService) GetDefaultPublication(ctx context.Context) (string, error) { 403 + if m.GetDefaultPublicationFunc != nil { 404 + return m.GetDefaultPublicationFunc(ctx) 405 + } 406 + 407 + // Default returns a mock publication URI 408 + if !m.IsAuthenticatedVal { 409 + return "", errors.New("not authenticated") 410 + } 411 + return "at://did:plc:test123/pub.leaflet.publication/mock_pub_rkey", nil 412 } 413 414 // Close mocks cleanup
+75
internal/shared/shared.go
··· 4 import ( 5 "errors" 6 "fmt" 7 ) 8 9 var ( ··· 17 func IsConfigError(err error) bool { 18 return errors.Is(err, ErrConfig) 19 }
··· 4 import ( 5 "errors" 6 "fmt" 7 + "io" 8 + "os" 9 + "path/filepath" 10 + "time" 11 + 12 + "github.com/charmbracelet/log" 13 ) 14 15 var ( ··· 23 func IsConfigError(err error) bool { 24 return errors.Is(err, ErrConfig) 25 } 26 + 27 + // CompoundWriter writes every payload to two sinks: 28 + // 1. a primary sink (typically [os.Stdout] or [os.Stderr]) 29 + // 2. a secondary sink (typically [*os.File]) 30 + // 31 + // It satisfies io.Writer. 32 + type CompoundWriter struct { 33 + primary io.Writer 34 + secondary io.Writer 35 + } 36 + 37 + // New creates a new [CompoundWriter] 38 + func New(primary io.Writer, secondary io.Writer) *CompoundWriter { 39 + return &CompoundWriter{ 40 + primary: primary, 41 + secondary: secondary, 42 + } 43 + } 44 + 45 + func LogWithStdErr(w io.WriteCloser) *CompoundWriter { 46 + return New(os.Stderr, w) 47 + } 48 + 49 + func LogWithStdOut(w io.WriteCloser) *CompoundWriter { 50 + return New(os.Stdout, w) 51 + } 52 + 53 + // Write writes p to both instances of [io.Writer] 54 + func (cw *CompoundWriter) Write(p []byte) (int, error) { 55 + var err error 56 + var n1, n2 int 57 + 58 + if n1, err = cw.primary.Write(p); err != nil { 59 + return n1, err 60 + } 61 + if n2, err = cw.secondary.Write(p); err != nil { 62 + return n2, err 63 + } 64 + return len(p), nil 65 + } 66 + 67 + func FallbackLogger() *log.Logger { 68 + return log.NewWithOptions(os.Stderr, log.Options{ 69 + Prefix: "[DEBUG]", 70 + ReportTimestamp: true, 71 + ReportCaller: true, 72 + TimeFormat: time.Kitchen, 73 + Level: log.DebugLevel, 74 + }) 75 + } 76 + 77 + // NewDebugLoggerWithFile creates a new debug logger that writes to both stderr and a log file 78 + func NewDebugLoggerWithFile(configDir string) *log.Logger { 79 + logger := FallbackLogger() 80 + logsDir := filepath.Join(configDir, "logs") 81 + if err := os.MkdirAll(logsDir, 0755); err != nil { 82 + return logger 83 + } 84 + 85 + logFile := filepath.Join(logsDir, fmt.Sprintf("publication_%s.log", time.Now().Format("2006-01-02"))) 86 + file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) 87 + if err != nil { 88 + return logger 89 + } 90 + 91 + w := LogWithStdErr(file) 92 + logger.SetOutput(w) 93 + return logger 94 + }
+260 -39
internal/shared/shared_test.go
··· 1 package shared 2 3 import ( 4 "errors" 5 "testing" 6 ) 7 8 - func TestErrors(t *testing.T) { 9 - t.Run("ConfigError", func(t *testing.T) { 10 - t.Run("creates joined error with message", func(t *testing.T) { 11 - baseErr := errors.New("invalid format") 12 - err := ConfigError("database connection failed", baseErr) 13 14 - AssertError(t, err, "ConfigError should create an error") 15 - AssertContains(t, err.Error(), "configuration error", "error should contain config error marker") 16 - AssertContains(t, err.Error(), "database connection failed", "error should contain custom message") 17 - AssertContains(t, err.Error(), "invalid format", "error should contain base error") 18 }) 19 20 - t.Run("preserves both error chains", func(t *testing.T) { 21 - baseErr := errors.New("connection timeout") 22 - err := ConfigError("failed to connect", baseErr) 23 24 - AssertTrue(t, errors.Is(err, ErrConfig), "should identify as config error") 25 - AssertTrue(t, errors.Is(err, baseErr), "should preserve original error in chain") 26 }) 27 28 - t.Run("wraps multiple errors with Join", func(t *testing.T) { 29 - baseErr := errors.New("parse error") 30 - err := ConfigError("invalid config file", baseErr) 31 32 - AssertTrue(t, errors.Is(err, ErrConfig), "joined error should contain ErrConfig") 33 - AssertTrue(t, errors.Is(err, baseErr), "joined error should contain base error") 34 }) 35 - }) 36 37 - t.Run("IsConfigError", func(t *testing.T) { 38 - t.Run("identifies config errors", func(t *testing.T) { 39 - baseErr := errors.New("test error") 40 - err := ConfigError("test message", baseErr) 41 42 - AssertTrue(t, IsConfigError(err), "should identify config error") 43 }) 44 45 - t.Run("returns false for regular errors", func(t *testing.T) { 46 - err := errors.New("regular error") 47 48 - AssertFalse(t, IsConfigError(err), "should not identify regular error as config error") 49 }) 50 51 - t.Run("returns false for nil error", func(t *testing.T) { 52 - AssertFalse(t, IsConfigError(nil), "should return false for nil error") 53 }) 54 55 - t.Run("returns false for wrapped non-config errors", func(t *testing.T) { 56 - baseErr := errors.New("base error") 57 - wrappedErr := errors.New("wrapped: " + baseErr.Error()) 58 59 - AssertFalse(t, IsConfigError(wrappedErr), "should not identify wrapped non-config error") 60 }) 61 62 - t.Run("identifies wrapped config errors", func(t *testing.T) { 63 - baseErr := errors.New("original error") 64 - configErr := ConfigError("config issue", baseErr) 65 - wrappedAgain := errors.Join(errors.New("outer error"), configErr) 66 67 - AssertTrue(t, IsConfigError(wrappedAgain), "should identify config error in join chain") 68 }) 69 }) 70 }
··· 1 package shared 2 3 import ( 4 + "bytes" 5 "errors" 6 + "io" 7 "testing" 8 ) 9 10 + func TestCompoundWriter(t *testing.T) { 11 + t.Run("New", func(t *testing.T) { 12 + t.Run("creates writer with primary and secondary", func(t *testing.T) { 13 + var primary bytes.Buffer 14 + var secondary bytes.Buffer 15 + 16 + cw := New(&primary, &secondary) 17 18 + if cw == nil { 19 + t.Fatal("Expected CompoundWriter to be created") 20 + } 21 + if cw.primary == nil { 22 + t.Error("Expected primary writer to be set") 23 + } 24 + if cw.secondary == nil { 25 + t.Error("Expected secondary writer to be set") 26 + } 27 }) 28 + }) 29 + 30 + t.Run("Write", func(t *testing.T) { 31 + t.Run("writes to both primary and secondary", func(t *testing.T) { 32 + var primary bytes.Buffer 33 + var secondary bytes.Buffer 34 + 35 + cw := New(&primary, &secondary) 36 + 37 + testData := []byte("test message") 38 + n, err := cw.Write(testData) 39 40 + if err != nil { 41 + t.Errorf("Expected no error, got %v", err) 42 + } 43 + if n != len(testData) { 44 + t.Errorf("Expected to write %d bytes, got %d", len(testData), n) 45 + } 46 47 + if primary.String() != "test message" { 48 + t.Errorf("Expected primary to contain 'test message', got '%s'", primary.String()) 49 + } 50 + if secondary.String() != "test message" { 51 + t.Errorf("Expected secondary to contain 'test message', got '%s'", secondary.String()) 52 + } 53 }) 54 55 + t.Run("writes multiple times to both sinks", func(t *testing.T) { 56 + var primary bytes.Buffer 57 + var secondary bytes.Buffer 58 + 59 + cw := New(&primary, &secondary) 60 61 + messages := []string{"first", "second", "third"} 62 + for _, msg := range messages { 63 + _, err := cw.Write([]byte(msg)) 64 + if err != nil { 65 + t.Errorf("Expected no error writing '%s', got %v", msg, err) 66 + } 67 + } 68 + 69 + expected := "firstsecondthird" 70 + if primary.String() != expected { 71 + t.Errorf("Expected primary to contain '%s', got '%s'", expected, primary.String()) 72 + } 73 + if secondary.String() != expected { 74 + t.Errorf("Expected secondary to contain '%s', got '%s'", expected, secondary.String()) 75 + } 76 }) 77 + 78 + t.Run("returns error from primary writer", func(t *testing.T) { 79 + var secondary bytes.Buffer 80 + expectedErr := errors.New("primary write failed") 81 + primary := &errorWriter{err: expectedErr} 82 + 83 + cw := New(primary, &secondary) 84 + 85 + _, err := cw.Write([]byte("test")) 86 + 87 + if err == nil { 88 + t.Error("Expected error from primary writer") 89 + } 90 + if !errors.Is(err, expectedErr) { 91 + t.Errorf("Expected error '%v', got '%v'", expectedErr, err) 92 + } 93 + }) 94 + 95 + t.Run("returns error from secondary writer", func(t *testing.T) { 96 + var primary bytes.Buffer 97 + expectedErr := errors.New("secondary write failed") 98 + secondary := &errorWriter{err: expectedErr} 99 + 100 + cw := New(&primary, secondary) 101 + 102 + _, err := cw.Write([]byte("test")) 103 + 104 + if err == nil { 105 + t.Error("Expected error from secondary writer") 106 + } 107 + if !errors.Is(err, expectedErr) { 108 + t.Errorf("Expected error '%v', got '%v'", expectedErr, err) 109 + } 110 + }) 111 112 + t.Run("writes to primary even if secondary fails", func(t *testing.T) { 113 + var primary bytes.Buffer 114 + expectedErr := errors.New("secondary write failed") 115 + secondary := &errorWriter{err: expectedErr} 116 + 117 + cw := New(&primary, secondary) 118 + 119 + testData := []byte("test message") 120 + _, _ = cw.Write(testData) 121 122 + if primary.String() != "test message" { 123 + t.Errorf("Expected primary to contain 'test message' even with secondary error, got '%s'", primary.String()) 124 + } 125 }) 126 127 + t.Run("handles empty write", func(t *testing.T) { 128 + var primary bytes.Buffer 129 + var secondary bytes.Buffer 130 + 131 + cw := New(&primary, &secondary) 132 133 + n, err := cw.Write([]byte{}) 134 + 135 + if err != nil { 136 + t.Errorf("Expected no error on empty write, got %v", err) 137 + } 138 + if n != 0 { 139 + t.Errorf("Expected to write 0 bytes, got %d", n) 140 + } 141 }) 142 143 + t.Run("handles large write", func(t *testing.T) { 144 + var primary bytes.Buffer 145 + var secondary bytes.Buffer 146 + 147 + cw := New(&primary, &secondary) 148 + 149 + largeData := make([]byte, 1024*1024) // 1MB 150 + for i := range largeData { 151 + largeData[i] = byte(i % 256) 152 + } 153 + 154 + n, err := cw.Write(largeData) 155 + 156 + if err != nil { 157 + t.Errorf("Expected no error on large write, got %v", err) 158 + } 159 + if n != len(largeData) { 160 + t.Errorf("Expected to write %d bytes, got %d", len(largeData), n) 161 + } 162 + 163 + if !bytes.Equal(primary.Bytes(), largeData) { 164 + t.Error("Primary writer didn't receive correct data") 165 + } 166 + if !bytes.Equal(secondary.Bytes(), largeData) { 167 + t.Error("Secondary writer didn't receive correct data") 168 + } 169 }) 170 + }) 171 + 172 + t.Run("WithStdErr", func(t *testing.T) { 173 + t.Run("creates writer with stderr as primary", func(t *testing.T) { 174 + var buf bytes.Buffer 175 + closer := &nopCloser{Writer: &buf} 176 + 177 + cw := LogWithStdErr(closer) 178 + 179 + if cw == nil { 180 + t.Fatal("Expected CompoundWriter to be created") 181 + } 182 183 + testData := []byte("test") 184 + _, err := cw.Write(testData) 185 + if err != nil { 186 + t.Errorf("Expected no error, got %v", err) 187 + } 188 189 + if buf.String() != "test" { 190 + t.Errorf("Expected secondary to contain 'test', got '%s'", buf.String()) 191 + } 192 }) 193 + }) 194 195 + t.Run("WithStdOut", func(t *testing.T) { 196 + t.Run("creates writer with stdout as primary", func(t *testing.T) { 197 + var buf bytes.Buffer 198 + closer := &nopCloser{Writer: &buf} 199 + 200 + cw := LogWithStdOut(closer) 201 + 202 + if cw == nil { 203 + t.Fatal("Expected CompoundWriter to be created") 204 + } 205 206 + testData := []byte("test") 207 + _, err := cw.Write(testData) 208 + if err != nil { 209 + t.Errorf("Expected no error, got %v", err) 210 + } 211 + 212 + if buf.String() != "test" { 213 + t.Errorf("Expected secondary to contain 'test', got '%s'", buf.String()) 214 + } 215 }) 216 }) 217 } 218 + 219 + func TestConfigError(t *testing.T) { 220 + t.Run("wraps error with message", func(t *testing.T) { 221 + originalErr := errors.New("original error") 222 + configErr := ConfigError("test message", originalErr) 223 + 224 + if configErr == nil { 225 + t.Fatal("Expected error to be returned") 226 + } 227 + 228 + errMsg := configErr.Error() 229 + if errMsg != "configuration error\ntest message: original error" { 230 + t.Errorf("Expected specific error format, got '%s'", errMsg) 231 + } 232 + }) 233 + 234 + t.Run("is detectable with IsConfigError", func(t *testing.T) { 235 + originalErr := errors.New("original error") 236 + configErr := ConfigError("test message", originalErr) 237 + 238 + if !IsConfigError(configErr) { 239 + t.Error("Expected IsConfigError to return true") 240 + } 241 + }) 242 + 243 + t.Run("wraps original error", func(t *testing.T) { 244 + originalErr := errors.New("original error") 245 + configErr := ConfigError("test message", originalErr) 246 + 247 + if !errors.Is(configErr, originalErr) { 248 + t.Error("Expected config error to wrap original error") 249 + } 250 + }) 251 + } 252 + 253 + func TestIsConfigError(t *testing.T) { 254 + t.Run("returns true for config error", func(t *testing.T) { 255 + configErr := ConfigError("test", errors.New("inner")) 256 + 257 + if !IsConfigError(configErr) { 258 + t.Error("Expected IsConfigError to return true for config error") 259 + } 260 + }) 261 + 262 + t.Run("returns false for non-config error", func(t *testing.T) { 263 + regularErr := errors.New("regular error") 264 + 265 + if IsConfigError(regularErr) { 266 + t.Error("Expected IsConfigError to return false for regular error") 267 + } 268 + }) 269 + 270 + t.Run("returns false for nil error", func(t *testing.T) { 271 + if IsConfigError(nil) { 272 + t.Error("Expected IsConfigError to return false for nil error") 273 + } 274 + }) 275 + } 276 + 277 + type errorWriter struct { 278 + err error 279 + } 280 + 281 + func (w *errorWriter) Write(p []byte) (int, error) { 282 + return 0, w.err 283 + } 284 + 285 + type nopCloser struct { 286 + io.Writer 287 + } 288 + 289 + func (nc *nopCloser) Close() error { 290 + return nil 291 + }
+8 -26
internal/ui/auth_form.go
··· 9 "github.com/charmbracelet/bubbles/key" 10 "github.com/charmbracelet/bubbles/textinput" 11 tea "github.com/charmbracelet/bubbletea" 12 - "github.com/charmbracelet/lipgloss" 13 ) 14 15 // AuthFormOptions configures the auth form display ··· 171 func (m authFormModel) View() string { 172 var b strings.Builder 173 174 - titleStyle := lipgloss.NewStyle(). 175 - Bold(true). 176 - Foreground(lipgloss.Color("6")). 177 - MarginBottom(1) 178 - 179 - labelStyle := lipgloss.NewStyle(). 180 - Foreground(lipgloss.Color("7")) 181 - 182 - helpStyle := lipgloss.NewStyle(). 183 - Foreground(lipgloss.Color("8")). 184 - MarginTop(1) 185 - 186 - errorStyle := lipgloss.NewStyle(). 187 - Foreground(lipgloss.Color("9")) 188 - 189 - b.WriteString(titleStyle.Render("AT Protocol Authentication")) 190 b.WriteString("\n\n") 191 192 - b.WriteString(labelStyle.Render("BlueSky Handle:")) 193 b.WriteString("\n") 194 if m.handleLocked { 195 - lockedStyle := lipgloss.NewStyle(). 196 - Foreground(lipgloss.Color("8")) 197 - b.WriteString(lockedStyle.Render(m.handleInput.Value())) 198 - b.WriteString(lockedStyle.Render(" (locked)")) 199 } else { 200 b.WriteString(m.handleInput.View()) 201 } 202 b.WriteString("\n\n") 203 204 - b.WriteString(labelStyle.Render("App Password:")) 205 b.WriteString("\n") 206 b.WriteString(m.passwordInput.View()) 207 b.WriteString("\n\n") 208 209 if m.handleInput.Value() == "" { 210 - b.WriteString(errorStyle.Render("Handle is required")) 211 b.WriteString("\n") 212 } 213 if m.passwordInput.Value() == "" { 214 - b.WriteString(errorStyle.Render("Password is required")) 215 b.WriteString("\n") 216 } 217 218 b.WriteString("\n") 219 helpText := "tab/shift+tab: navigate โ€ข enter/ctrl+s: submit โ€ข esc/ctrl+c: cancel" 220 - b.WriteString(helpStyle.Render(helpText)) 221 222 return b.String() 223 }
··· 9 "github.com/charmbracelet/bubbles/key" 10 "github.com/charmbracelet/bubbles/textinput" 11 tea "github.com/charmbracelet/bubbletea" 12 ) 13 14 // AuthFormOptions configures the auth form display ··· 170 func (m authFormModel) View() string { 171 var b strings.Builder 172 173 + b.WriteString(TitleStyle.Render("AT Protocol Authentication")) 174 b.WriteString("\n\n") 175 176 + b.WriteString(TextStyle.Render("BlueSky Handle:")) 177 b.WriteString("\n") 178 if m.handleLocked { 179 + b.WriteString(MutedStyle.Render(m.handleInput.Value())) 180 + b.WriteString(MutedStyle.Render(" (locked)")) 181 } else { 182 b.WriteString(m.handleInput.View()) 183 } 184 b.WriteString("\n\n") 185 186 + b.WriteString(TextStyle.Render("App Password:")) 187 b.WriteString("\n") 188 b.WriteString(m.passwordInput.View()) 189 b.WriteString("\n\n") 190 191 if m.handleInput.Value() == "" { 192 + b.WriteString(ErrorStyle.Render("Handle is required")) 193 b.WriteString("\n") 194 } 195 if m.passwordInput.Value() == "" { 196 + b.WriteString(ErrorStyle.Render("Password is required")) 197 b.WriteString("\n") 198 } 199 200 b.WriteString("\n") 201 helpText := "tab/shift+tab: navigate โ€ข enter/ctrl+s: submit โ€ข esc/ctrl+c: cancel" 202 + b.WriteString(MutedStyle.MarginTop(1).Render(helpText)) 203 204 return b.String() 205 }