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

feat(pub): update publication pull

- Implemented GetNewestPublication to retrieve the most recently published leaflet note.

- Added DeleteAllLeafletNotes to remove all leaflet notes

- enhance ATProtoService with CBOR to JSON conversion

- Updated PullDocuments to handle CBOR decoding and JSON conversion.

- Integrated viewport for viewing content in data_list.go.

- create PublicationView for displaying notes as markdown

+2519 -96
+27
cmd/publication_commands.go
··· 149 listCmd.Flags().BoolP("interactive", "i", false, "Open interactive TUI browser") 150 root.AddCommand(listCmd) 151 152 statusCmd := &cobra.Command{ 153 Use: "status", 154 Short: "Show leaflet authentication status",
··· 149 listCmd.Flags().BoolP("interactive", "i", false, "Open interactive TUI browser") 150 root.AddCommand(listCmd) 151 152 + readCmd := &cobra.Command{ 153 + Use: "read [identifier]", 154 + Short: "Read a publication", 155 + Long: `Display a publication's content with formatted markdown rendering. 156 + 157 + The identifier can be: 158 + - Omitted: Display the newest publication 159 + - Database ID: Display publication by note ID (e.g., 42) 160 + - AT Protocol rkey: Display publication by leaflet rkey 161 + 162 + Examples: 163 + noteleaf pub read # Show newest publication 164 + noteleaf pub read 123 # Show publication with note ID 123 165 + noteleaf pub read 3jxx... # Show publication by rkey`, 166 + Args: cobra.MaximumNArgs(1), 167 + RunE: func(cmd *cobra.Command, args []string) error { 168 + identifier := "" 169 + if len(args) > 0 { 170 + identifier = args[0] 171 + } 172 + 173 + defer c.handler.Close() 174 + return c.handler.Read(cmd.Context(), identifier) 175 + }, 176 + } 177 + root.AddCommand(readCmd) 178 + 179 statusCmd := &cobra.Command{ 180 Use: "status", 181 Short: "Show leaflet authentication status",
+68
cmd/publication_commands_test.go
··· 5 "strings" 6 "testing" 7 8 "github.com/stormlightlabs/noteleaf/internal/handlers" 9 ) 10 11 func createTestPublicationHandler(t *testing.T) (*handlers.PublicationHandler, func()) { ··· 65 "auth [handle]", 66 "pull", 67 "list [--published|--draft|--all] [--interactive]", 68 "status", 69 "post [note-id]", 70 "patch [note-id]", ··· 157 158 if err != nil { 159 t.Errorf("list with multiple flags failed: %v", err) 160 } 161 }) 162 })
··· 5 "strings" 6 "testing" 7 8 + "github.com/spf13/cobra" 9 "github.com/stormlightlabs/noteleaf/internal/handlers" 10 + "github.com/stormlightlabs/noteleaf/internal/shared" 11 ) 12 13 func createTestPublicationHandler(t *testing.T) (*handlers.PublicationHandler, func()) { ··· 67 "auth [handle]", 68 "pull", 69 "list [--published|--draft|--all] [--interactive]", 70 + "read [identifier]", 71 "status", 72 "post [note-id]", 73 "patch [note-id]", ··· 160 161 if err != nil { 162 t.Errorf("list with multiple flags failed: %v", err) 163 + } 164 + }) 165 + }) 166 + 167 + t.Run("Read Command", func(t *testing.T) { 168 + t.Run("reads without identifier", func(t *testing.T) { 169 + handler, cleanup := createTestPublicationHandler(t) 170 + defer cleanup() 171 + 172 + cmd := NewPublicationCommand(handler).Create() 173 + cmd.SetArgs([]string{"read"}) 174 + err := cmd.Execute() 175 + 176 + if err == nil { 177 + t.Error("Expected read to fail when no publications exist") 178 + } 179 + 180 + shared.AssertErrorContains(t, err, "note not found", "") 181 + }) 182 + 183 + t.Run("fails with non-existent note ID", func(t *testing.T) { 184 + handler, cleanup := createTestPublicationHandler(t) 185 + defer cleanup() 186 + 187 + cmd := NewPublicationCommand(handler).Create() 188 + cmd.SetArgs([]string{"read", "999"}) 189 + err := cmd.Execute() 190 + 191 + shared.AssertError(t, err, "read to fail with non-existent ID") 192 + }) 193 + 194 + t.Run("fails with non-existent rkey", func(t *testing.T) { 195 + handler, cleanup := createTestPublicationHandler(t) 196 + defer cleanup() 197 + 198 + cmd := NewPublicationCommand(handler).Create() 199 + cmd.SetArgs([]string{"read", "3jxxxxxxxxxxxx"}) 200 + err := cmd.Execute() 201 + 202 + if err == nil { 203 + t.Error("Expected read to fail with non-existent rkey") 204 + } 205 + }) 206 + 207 + t.Run("accepts optional identifier argument", func(t *testing.T) { 208 + handler, cleanup := createTestPublicationHandler(t) 209 + defer cleanup() 210 + 211 + cmd := NewPublicationCommand(handler).Create() 212 + subcommands := cmd.Commands() 213 + 214 + var readCmd *cobra.Command 215 + for _, subcmd := range subcommands { 216 + if strings.HasPrefix(subcmd.Use, "read") { 217 + readCmd = subcmd 218 + break 219 + } 220 + } 221 + 222 + if readCmd == nil { 223 + t.Fatal("read command not found") 224 + } 225 + 226 + if readCmd.Use != "read [identifier]" { 227 + t.Errorf("Expected Use to be 'read [identifier]', got '%s'", readCmd.Use) 228 } 229 }) 230 })
+2
go.mod
··· 28 github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 29 github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 30 github.com/felixge/httpsnoop v1.0.4 // indirect 31 github.com/go-logr/logr v1.4.1 // indirect 32 github.com/go-logr/stdr v1.2.2 // indirect 33 github.com/gogo/protobuf v1.3.2 // indirect ··· 73 github.com/russross/blackfriday/v2 v2.1.0 // indirect 74 github.com/spaolacci/murmur3 v1.1.0 // indirect 75 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 76 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 77 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 78 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
··· 28 github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 29 github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 30 github.com/felixge/httpsnoop v1.0.4 // indirect 31 + github.com/fxamacker/cbor/v2 v2.9.0 32 github.com/go-logr/logr v1.4.1 // indirect 33 github.com/go-logr/stdr v1.2.2 // indirect 34 github.com/gogo/protobuf v1.3.2 // indirect ··· 74 github.com/russross/blackfriday/v2 v2.1.0 // indirect 75 github.com/spaolacci/murmur3 v1.1.0 // indirect 76 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 77 + github.com/x448/float16 v0.8.4 // indirect 78 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 79 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 80 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
+4
go.sum
··· 85 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 86 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 87 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 88 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 89 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 90 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= ··· 361 github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11/go.mod h1:Wlo/SzPmxVp6vXpGt/zaXhHH0fn4IxgqZc82aKg6bpQ= 362 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 363 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 364 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 365 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 366 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
··· 85 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 86 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 87 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 88 + github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= 89 + github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 90 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 91 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 92 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= ··· 363 github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11/go.mod h1:Wlo/SzPmxVp6vXpGt/zaXhHH0fn4IxgqZc82aKg6bpQ= 364 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 365 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 366 + github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 367 + github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 368 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 369 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 370 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+21 -7
internal/docs/ROADMAP.md
··· 40 - [x] Confirm filtering by tags and `--archived`. 41 - [x] Ensure notes can be opened, edited in `$EDITOR`, and deleted. 42 43 ### Media Domains 44 45 #### Books ··· 92 93 ### QA 94 95 - - [ ] Verify **unit tests** for all handlers (TaskHandler, NoteHandler, Media Handlers). 96 - - [ ] Write **integration tests** covering CLI flows. 97 - - [ ] Ensure error handling works for: 98 - Invalid IDs 99 - Invalid flags 100 - Schema corruption (already tested in repo) ··· 356 - Installation and usage documentation 357 - Contribution guide and developer docs 358 - Consistent argument parsing 359 - Backup/restore 360 - Multiple profiles 361 - Optional synchronization ··· 367 | Tasks | CRUD | Complete | 368 | Tasks | Projects/tags | Complete | 369 | Tasks | Time tracking | Complete | 370 - | Tasks | Dependencies | Planned | 371 - | Tasks | Recurrence | Planned | 372 | Tasks | Wait/scheduled | Planned | 373 | Tasks | Urgency scoring | Planned | 374 | Notes | CRUD | Complete | 375 | Notes | Search/tagging | Planned | 376 | Media | Books/movies/TV | Complete | 377 - | Media | Articles | Planned | 378 | Media | Source/ratings | Planned | 379 - | Articles | Parser + storage | Planned | 380 | System | SQLite persistence | Complete | 381 | System | Synchronization | Future | 382 | System | Import/export formats | Future |
··· 40 - [x] Confirm filtering by tags and `--archived`. 41 - [x] Ensure notes can be opened, edited in `$EDITOR`, and deleted. 42 43 + #### Publication 44 + 45 + - [x] Implement authentication with BlueSky/leaflet (AT Protocol). 46 + - [ ] Add OAuth2 47 + - [x] Verify `pub pull` fetches and syncs documents from leaflet. 48 + - [x] Confirm `pub list` with status filtering (`all`, `published`, `draft`). 49 + - [ ] Test `pub post` creates new documents with draft/preview/validate modes. 50 + - [ ] Ensure `pub patch` updates existing documents correctly. 51 + - [ ] Validate `pub push` handles batch operations (create/update). 52 + - [ ] Verify markdown conversion to leaflet block format (headings, code, images, facets). 53 + 54 ### Media Domains 55 56 #### Books ··· 103 104 ### QA 105 106 + - [x] Verify **unit tests** for all handlers (TaskHandler, NoteHandler, Media Handlers). 107 + - [x] Write **integration tests** covering CLI flows. 108 + - [x] Ensure error handling works for: 109 - Invalid IDs 110 - Invalid flags 111 - Schema corruption (already tested in repo) ··· 367 - Installation and usage documentation 368 - Contribution guide and developer docs 369 - Consistent argument parsing 370 + 371 + #### Post v1 372 + 373 - Backup/restore 374 - Multiple profiles 375 - Optional synchronization ··· 381 | Tasks | CRUD | Complete | 382 | Tasks | Projects/tags | Complete | 383 | Tasks | Time tracking | Complete | 384 + | Tasks | Dependencies | Complete | 385 + | Tasks | Recurrence | Complete | 386 | Tasks | Wait/scheduled | Planned | 387 | Tasks | Urgency scoring | Planned | 388 | Notes | CRUD | Complete | 389 | Notes | Search/tagging | Planned | 390 | Media | Books/movies/TV | Complete | 391 + | Media | Articles | Complete | 392 | Media | Source/ratings | Planned | 393 + | Articles | Parser + storage | Complete | 394 | System | SQLite persistence | Complete | 395 | System | Synchronization | Future | 396 | System | Import/export formats | Future |
+79 -55
internal/handlers/publication.go
··· 126 return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 127 } 128 129 ui.Infoln("Fetching documents from leaflet...") 130 131 docs, err := h.atproto.PullDocuments(ctx) ··· 140 141 ui.Infoln("Found %d document(s). Syncing...\n", len(docs)) 142 143 - var created, updated int 144 145 for _, doc := range docs { 146 - existing, err := h.repos.Notes.GetByLeafletRKey(ctx, doc.Meta.RKey) 147 - if err == nil && existing != nil { 148 - content, err := documentToMarkdown(doc) 149 - if err != nil { 150 - ui.Warningln("Skipping document %s: %v", doc.Document.Title, err) 151 - continue 152 - } 153 154 - existing.Title = doc.Document.Title 155 - existing.Content = content 156 - existing.LeafletCID = &doc.Meta.CID 157 - existing.IsDraft = doc.Meta.IsDraft 158 159 - if doc.Document.PublishedAt != "" { 160 - publishedAt, err := time.Parse(time.RFC3339, doc.Document.PublishedAt) 161 - if err == nil { 162 - existing.PublishedAt = &publishedAt 163 - } 164 } 165 166 - if err := h.repos.Notes.Update(ctx, existing); err != nil { 167 - ui.Warningln("Failed to update note for document %s: %v", doc.Document.Title, err) 168 - continue 169 - } 170 - 171 - updated++ 172 - ui.Infoln(" Updated: %s", doc.Document.Title) 173 - } else { 174 - content, err := documentToMarkdown(doc) 175 - if err != nil { 176 - ui.Warningln("Skipping document %s: %v", doc.Document.Title, err) 177 - continue 178 - } 179 - 180 - note := &models.Note{ 181 - Title: doc.Document.Title, 182 - Content: content, 183 - LeafletRKey: &doc.Meta.RKey, 184 - LeafletCID: &doc.Meta.CID, 185 - IsDraft: doc.Meta.IsDraft, 186 - } 187 - 188 - if doc.Document.PublishedAt != "" { 189 - publishedAt, err := time.Parse(time.RFC3339, doc.Document.PublishedAt) 190 - if err == nil { 191 - note.PublishedAt = &publishedAt 192 - } 193 - } 194 - 195 - _, err = h.repos.Notes.Create(ctx, note) 196 - if err != nil { 197 - ui.Warningln("Failed to create note for document %s: %v", doc.Document.Title, err) 198 - continue 199 - } 200 - 201 - created++ 202 - ui.Infoln(" Created: %s", doc.Document.Title) 203 } 204 } 205 206 - ui.Successln("Sync complete: %d created, %d updated", created, updated) 207 return nil 208 } 209 ··· 462 463 list := ui.NewPublicationDataList(h.repos.Notes, opts, filter) 464 return list.Browse(ctx) 465 } 466 467 // prepareDocumentForPublish prepares a note for publication by converting to Leaflet format
··· 126 return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 127 } 128 129 + if configDir, err := store.GetConfigDir(); err == nil { 130 + logFile := filepath.Join(configDir, "logs", fmt.Sprintf("publication_%s.log", time.Now().Format("2006-01-02"))) 131 + ui.Infoln("Detailed logs: %s", logFile) 132 + } 133 + 134 ui.Infoln("Fetching documents from leaflet...") 135 136 docs, err := h.atproto.PullDocuments(ctx) ··· 145 146 ui.Infoln("Found %d document(s). Syncing...\n", len(docs)) 147 148 + ui.Infoln("Removing existing publications...") 149 + if err := h.repos.Notes.DeleteAllLeafletNotes(ctx); err != nil { 150 + return fmt.Errorf("failed to delete existing publications: %w", err) 151 + } 152 + 153 + var created, failed int 154 155 for _, doc := range docs { 156 + content, err := documentToMarkdown(doc) 157 + if err != nil { 158 + ui.Warningln("Skipping document %s: %v", doc.Document.Title, err) 159 + failed++ 160 + continue 161 + } 162 163 + note := &models.Note{ 164 + Title: doc.Document.Title, 165 + Content: content, 166 + LeafletRKey: &doc.Meta.RKey, 167 + LeafletCID: &doc.Meta.CID, 168 + IsDraft: doc.Meta.IsDraft, 169 + } 170 171 + if doc.Document.PublishedAt != "" { 172 + publishedAt, err := time.Parse(time.RFC3339, doc.Document.PublishedAt) 173 + if err == nil { 174 + note.PublishedAt = &publishedAt 175 } 176 + } 177 178 + _, err = h.repos.Notes.Create(ctx, note) 179 + if err != nil { 180 + ui.Warningln("Failed to create note for document %s: %v", doc.Document.Title, err) 181 + failed++ 182 + continue 183 } 184 + 185 + created++ 186 + ui.Infoln(" Created: %s", doc.Document.Title) 187 } 188 189 + if failed > 0 { 190 + ui.Successln("Sync complete: %d created, %d failed", created, failed) 191 + } else { 192 + ui.Successln("Sync complete: %d created", created) 193 + } 194 return nil 195 } 196 ··· 449 450 list := ui.NewPublicationDataList(h.repos.Notes, opts, filter) 451 return list.Browse(ctx) 452 + } 453 + 454 + // Read displays a publication's content with formatted markdown rendering. 455 + // The identifier can be: 456 + // - empty string: display the newest publication 457 + // - numeric string: treat as database ID 458 + // - non-numeric string: treat as AT Protocol rkey 459 + func (h *PublicationHandler) Read(ctx context.Context, identifier string) error { 460 + var note *models.Note 461 + var err error 462 + 463 + if identifier == "" { 464 + note, err = h.repos.Notes.GetNewestPublication(ctx) 465 + if err != nil { 466 + return fmt.Errorf("failed to get newest publication: %w", err) 467 + } 468 + } else { 469 + var id int64 470 + _, scanErr := fmt.Sscanf(identifier, "%d", &id) 471 + if scanErr == nil { 472 + note, err = h.repos.Notes.Get(ctx, id) 473 + if err != nil { 474 + return fmt.Errorf("failed to get publication by ID: %w", err) 475 + } 476 + if !note.HasLeafletAssociation() { 477 + return fmt.Errorf("note %d is not a publication", id) 478 + } 479 + } else { 480 + note, err = h.repos.Notes.GetByLeafletRKey(ctx, identifier) 481 + if err != nil { 482 + return fmt.Errorf("failed to get publication by rkey: %w", err) 483 + } 484 + } 485 + } 486 + 487 + view := ui.NewPublicationView(note, ui.PublicationViewOptions{}) 488 + return view.Show(ctx) 489 } 490 491 // prepareDocumentForPublish prepares a note for publication by converting to Leaflet format
+79
internal/handlers/publication_test.go
··· 2 3 import ( 4 "context" 5 "strings" 6 "testing" 7 "time" ··· 1964 1965 if err != nil && !strings.Contains(err.Error(), "not authenticated") { 1966 t.Logf("Got error during push (expected for external service call): %v", err) 1967 } 1968 }) 1969 })
··· 2 3 import ( 4 "context" 5 + "fmt" 6 "strings" 7 "testing" 8 "time" ··· 1965 1966 if err != nil && !strings.Contains(err.Error(), "not authenticated") { 1967 t.Logf("Got error during push (expected for external service call): %v", err) 1968 + } 1969 + }) 1970 + }) 1971 + 1972 + t.Run("Read", func(t *testing.T) { 1973 + t.Run("returns error when no publications exist", func(t *testing.T) { 1974 + suite := NewHandlerTestSuite(t) 1975 + defer suite.Cleanup() 1976 + 1977 + handler := CreateHandler(t, NewPublicationHandler) 1978 + ctx := context.Background() 1979 + 1980 + err := handler.Read(ctx, "") 1981 + if err == nil { 1982 + t.Error("Expected error when no publications exist") 1983 + } 1984 + 1985 + if !strings.Contains(err.Error(), "note not found") { 1986 + t.Errorf("Expected 'note not found' error, got '%v'", err) 1987 + } 1988 + }) 1989 + 1990 + t.Run("returns error for non-existent numeric ID", func(t *testing.T) { 1991 + suite := NewHandlerTestSuite(t) 1992 + defer suite.Cleanup() 1993 + 1994 + handler := CreateHandler(t, NewPublicationHandler) 1995 + ctx := context.Background() 1996 + 1997 + err := handler.Read(ctx, "999") 1998 + if err == nil { 1999 + t.Error("Expected error for non-existent ID") 2000 + } 2001 + 2002 + if !strings.Contains(err.Error(), "failed to get publication by ID") { 2003 + t.Errorf("Expected 'failed to get publication by ID' error, got '%v'", err) 2004 + } 2005 + }) 2006 + 2007 + t.Run("returns error when note by ID is not a publication", func(t *testing.T) { 2008 + suite := NewHandlerTestSuite(t) 2009 + defer suite.Cleanup() 2010 + 2011 + handler := CreateHandler(t, NewPublicationHandler) 2012 + ctx := context.Background() 2013 + 2014 + regularNote := &models.Note{ 2015 + Title: "Regular Note", 2016 + Content: "# Not a publication", 2017 + } 2018 + 2019 + id, err := handler.repos.Notes.Create(ctx, regularNote) 2020 + suite.AssertNoError(err, "create regular note") 2021 + 2022 + err = handler.Read(ctx, fmt.Sprintf("%d", id)) 2023 + if err == nil { 2024 + t.Error("Expected error when note is not a publication") 2025 + } 2026 + 2027 + if !strings.Contains(err.Error(), "not a publication") { 2028 + t.Errorf("Expected 'not a publication' error, got '%v'", err) 2029 + } 2030 + }) 2031 + 2032 + t.Run("returns error for non-existent rkey", func(t *testing.T) { 2033 + suite := NewHandlerTestSuite(t) 2034 + defer suite.Cleanup() 2035 + 2036 + handler := CreateHandler(t, NewPublicationHandler) 2037 + ctx := context.Background() 2038 + 2039 + err := handler.Read(ctx, "nonexistent_rkey") 2040 + if err == nil { 2041 + t.Error("Expected error for non-existent rkey") 2042 + } 2043 + 2044 + if !strings.Contains(err.Error(), "failed to get publication by rkey") { 2045 + t.Errorf("Expected 'failed to get publication by rkey' error, got '%v'", err) 2046 } 2047 }) 2048 })
+216 -4
internal/public/public.go
··· 7 // https://github.com/hyperlink-academy/leaflet/tree/main/lexicons/pub/leaflet/ 8 package public 9 10 - import "time" 11 12 const ( 13 - TypeDocument = "pub.leaflet.document" 14 - TypeDocumentDraft = "pub.leaflet.document.draft" 15 - TypePublication = "pub.leaflet.publication" 16 TypeLinearDocument = "pub.leaflet.pages.linearDocument" 17 TypeBlock = "pub.leaflet.pages.linearDocument#block" 18 ··· 64 Alignment string `json:"alignment,omitempty"` // #textAlignLeft, etc. 65 } 66 67 // TextBlock represents a text content block (pub.leaflet.blocks.text) 68 type TextBlock struct { 69 Type string `json:"$type"` ··· 122 Children []ListItem `json:"children,omitempty"` // Nested list items 123 } 124 125 // HorizontalRuleBlock represents a horizontal rule/thematic break (pub.leaflet.blocks.horizontalRule) 126 type HorizontalRuleBlock struct { 127 Type string `json:"$type"` ··· 132 Type string `json:"$type"` 133 Index ByteSlice `json:"index"` 134 Features []FacetFeature `json:"features"` 135 } 136 137 // ByteSlice specifies a substring range using UTF-8 byte offsets (pub.leaflet.richtext.facet#byteSlice)
··· 7 // https://github.com/hyperlink-academy/leaflet/tree/main/lexicons/pub/leaflet/ 8 package public 9 10 + import ( 11 + "encoding/json" 12 + "time" 13 + ) 14 15 const ( 16 + TypeDocument = "pub.leaflet.document" 17 + TypeDocumentDraft = "pub.leaflet.document.draft" 18 + TypePublication = "pub.leaflet.publication" 19 TypeLinearDocument = "pub.leaflet.pages.linearDocument" 20 TypeBlock = "pub.leaflet.pages.linearDocument#block" 21 ··· 67 Alignment string `json:"alignment,omitempty"` // #textAlignLeft, etc. 68 } 69 70 + type TypeCheck struct { 71 + Type string `json:"$type"` 72 + } 73 + 74 + // UnmarshalJSON custom unmarshaler for BlockWrap to properly type the Block field 75 + // 76 + // Matches against field $type to deserialize data 77 + func (bw *BlockWrap) UnmarshalJSON(data []byte) error { 78 + type Alias BlockWrap 79 + temp := &struct { 80 + Block json.RawMessage `json:"block"` 81 + *Alias 82 + }{ 83 + Alias: (*Alias)(bw), 84 + } 85 + 86 + if err := json.Unmarshal(data, temp); err != nil { 87 + return err 88 + } 89 + 90 + var typeCheck struct { 91 + Type string `json:"$type"` 92 + } 93 + if err := json.Unmarshal(temp.Block, &typeCheck); err != nil { 94 + return err 95 + } 96 + 97 + switch typeCheck.Type { 98 + case TypeTextBlock: 99 + var block TextBlock 100 + if err := json.Unmarshal(temp.Block, &block); err != nil { 101 + return err 102 + } 103 + bw.Block = block 104 + case TypeHeaderBlock: 105 + var block HeaderBlock 106 + if err := json.Unmarshal(temp.Block, &block); err != nil { 107 + return err 108 + } 109 + bw.Block = block 110 + case TypeCodeBlock: 111 + var block CodeBlock 112 + if err := json.Unmarshal(temp.Block, &block); err != nil { 113 + return err 114 + } 115 + bw.Block = block 116 + case TypeImageBlock: 117 + var block ImageBlock 118 + if err := json.Unmarshal(temp.Block, &block); err != nil { 119 + return err 120 + } 121 + bw.Block = block 122 + case TypeBlockquoteBlock: 123 + var block BlockquoteBlock 124 + if err := json.Unmarshal(temp.Block, &block); err != nil { 125 + return err 126 + } 127 + bw.Block = block 128 + case TypeUnorderedListBlock: 129 + var block UnorderedListBlock 130 + if err := json.Unmarshal(temp.Block, &block); err != nil { 131 + return err 132 + } 133 + bw.Block = block 134 + case TypeHorizontalRuleBlock: 135 + var block HorizontalRuleBlock 136 + if err := json.Unmarshal(temp.Block, &block); err != nil { 137 + return err 138 + } 139 + bw.Block = block 140 + default: 141 + var block map[string]any 142 + if err := json.Unmarshal(temp.Block, &block); err != nil { 143 + return err 144 + } 145 + bw.Block = block 146 + } 147 + 148 + return nil 149 + } 150 + 151 // TextBlock represents a text content block (pub.leaflet.blocks.text) 152 type TextBlock struct { 153 Type string `json:"$type"` ··· 206 Children []ListItem `json:"children,omitempty"` // Nested list items 207 } 208 209 + // UnmarshalJSON custom unmarshaler for ListItem to properly type the Content field 210 + func (li *ListItem) UnmarshalJSON(data []byte) error { 211 + type Alias ListItem 212 + temp := &struct { 213 + Content json.RawMessage `json:"content"` 214 + *Alias 215 + }{ 216 + Alias: (*Alias)(li), 217 + } 218 + 219 + if err := json.Unmarshal(data, temp); err != nil { 220 + return err 221 + } 222 + 223 + var typeCheck struct { 224 + Type string `json:"$type"` 225 + } 226 + if err := json.Unmarshal(temp.Content, &typeCheck); err != nil { 227 + return err 228 + } 229 + 230 + switch typeCheck.Type { 231 + case TypeTextBlock: 232 + var block TextBlock 233 + if err := json.Unmarshal(temp.Content, &block); err != nil { 234 + return err 235 + } 236 + li.Content = block 237 + case TypeHeaderBlock: 238 + var block HeaderBlock 239 + if err := json.Unmarshal(temp.Content, &block); err != nil { 240 + return err 241 + } 242 + li.Content = block 243 + case TypeImageBlock: 244 + var block ImageBlock 245 + if err := json.Unmarshal(temp.Content, &block); err != nil { 246 + return err 247 + } 248 + li.Content = block 249 + default: 250 + // For unknown types, leave as map 251 + var block map[string]any 252 + if err := json.Unmarshal(temp.Content, &block); err != nil { 253 + return err 254 + } 255 + li.Content = block 256 + } 257 + 258 + return nil 259 + } 260 + 261 // HorizontalRuleBlock represents a horizontal rule/thematic break (pub.leaflet.blocks.horizontalRule) 262 type HorizontalRuleBlock struct { 263 Type string `json:"$type"` ··· 268 Type string `json:"$type"` 269 Index ByteSlice `json:"index"` 270 Features []FacetFeature `json:"features"` 271 + } 272 + 273 + // UnmarshalJSON custom unmarshaler for Facet to properly type the Features field 274 + func (f *Facet) UnmarshalJSON(data []byte) error { 275 + type Alias Facet 276 + temp := &struct { 277 + Features []json.RawMessage `json:"features"` 278 + *Alias 279 + }{ 280 + Alias: (*Alias)(f), 281 + } 282 + 283 + if err := json.Unmarshal(data, temp); err != nil { 284 + return err 285 + } 286 + 287 + f.Features = make([]FacetFeature, 0, len(temp.Features)) 288 + for _, featureData := range temp.Features { 289 + var typeCheck TypeCheck 290 + if err := json.Unmarshal(featureData, &typeCheck); err != nil { 291 + return err 292 + } 293 + 294 + var feature FacetFeature 295 + switch typeCheck.Type { 296 + case TypeFacetBold: 297 + var fb FacetBold 298 + if err := json.Unmarshal(featureData, &fb); err != nil { 299 + return err 300 + } 301 + feature = fb 302 + case TypeFacetItalic: 303 + var fi FacetItalic 304 + if err := json.Unmarshal(featureData, &fi); err != nil { 305 + return err 306 + } 307 + feature = fi 308 + case TypeFacetCode: 309 + var fc FacetCode 310 + if err := json.Unmarshal(featureData, &fc); err != nil { 311 + return err 312 + } 313 + feature = fc 314 + case TypeFacetLink: 315 + var fl FacetLink 316 + if err := json.Unmarshal(featureData, &fl); err != nil { 317 + return err 318 + } 319 + feature = fl 320 + case TypeFacetStrike: 321 + var fs FacetStrikethrough 322 + if err := json.Unmarshal(featureData, &fs); err != nil { 323 + return err 324 + } 325 + feature = fs 326 + case TypeFacetUnderline: 327 + var fu FacetUnderline 328 + if err := json.Unmarshal(featureData, &fu); err != nil { 329 + return err 330 + } 331 + feature = fu 332 + case TypeFacetHighlight: 333 + var fh FacetHighlight 334 + if err := json.Unmarshal(featureData, &fh); err != nil { 335 + return err 336 + } 337 + feature = fh 338 + default: 339 + // Skip unknown feature types 340 + continue 341 + } 342 + 343 + f.Features = append(f.Features, feature) 344 + } 345 + 346 + return nil 347 } 348 349 // ByteSlice specifies a substring range using UTF-8 byte offsets (pub.leaflet.richtext.facet#byteSlice)
+899
internal/public/public_test.go
···
··· 1 + package public 2 + 3 + import ( 4 + "encoding/json" 5 + "testing" 6 + 7 + "github.com/stormlightlabs/noteleaf/internal/shared" 8 + ) 9 + 10 + func TestBlockWrap(t *testing.T) { 11 + t.Run("UnmarshalJSON", func(t *testing.T) { 12 + t.Run("unmarshals text block", func(t *testing.T) { 13 + jsonData := `{ 14 + "$type": "pub.leaflet.pages.linearDocument#block", 15 + "block": { 16 + "$type": "pub.leaflet.blocks.text", 17 + "plaintext": "Hello world" 18 + } 19 + }` 20 + 21 + var bw BlockWrap 22 + err := json.Unmarshal([]byte(jsonData), &bw) 23 + shared.AssertNoError(t, err, "unmarshal should succeed") 24 + shared.AssertEqual(t, TypeBlock, bw.Type, "type should match") 25 + 26 + block, ok := bw.Block.(TextBlock) 27 + shared.AssertTrue(t, ok, "block should be TextBlock") 28 + shared.AssertEqual(t, TypeTextBlock, block.Type, "block type should match") 29 + shared.AssertEqual(t, "Hello world", block.Plaintext, "plaintext should match") 30 + }) 31 + 32 + t.Run("unmarshals header block", func(t *testing.T) { 33 + jsonData := `{ 34 + "$type": "pub.leaflet.pages.linearDocument#block", 35 + "block": { 36 + "$type": "pub.leaflet.blocks.header", 37 + "level": 2, 38 + "plaintext": "Section Title" 39 + } 40 + }` 41 + 42 + var bw BlockWrap 43 + err := json.Unmarshal([]byte(jsonData), &bw) 44 + shared.AssertNoError(t, err, "unmarshal should succeed") 45 + 46 + block, ok := bw.Block.(HeaderBlock) 47 + shared.AssertTrue(t, ok, "block should be HeaderBlock") 48 + shared.AssertEqual(t, TypeHeaderBlock, block.Type, "block type should match") 49 + shared.AssertEqual(t, 2, block.Level, "level should match") 50 + shared.AssertEqual(t, "Section Title", block.Plaintext, "plaintext should match") 51 + }) 52 + 53 + t.Run("unmarshals code block", func(t *testing.T) { 54 + jsonData := `{ 55 + "$type": "pub.leaflet.pages.linearDocument#block", 56 + "block": { 57 + "$type": "pub.leaflet.blocks.code", 58 + "plaintext": "fmt.Println(\"test\")", 59 + "language": "go" 60 + } 61 + }` 62 + 63 + var bw BlockWrap 64 + err := json.Unmarshal([]byte(jsonData), &bw) 65 + shared.AssertNoError(t, err, "unmarshal should succeed") 66 + 67 + block, ok := bw.Block.(CodeBlock) 68 + shared.AssertTrue(t, ok, "block should be CodeBlock") 69 + shared.AssertEqual(t, TypeCodeBlock, block.Type, "block type should match") 70 + shared.AssertEqual(t, "go", block.Language, "language should match") 71 + shared.AssertEqual(t, "fmt.Println(\"test\")", block.Plaintext, "plaintext should match") 72 + }) 73 + 74 + t.Run("unmarshals image block", func(t *testing.T) { 75 + jsonData := `{ 76 + "$type": "pub.leaflet.pages.linearDocument#block", 77 + "block": { 78 + "$type": "pub.leaflet.blocks.image", 79 + "image": { 80 + "$type": "blob", 81 + "ref": { 82 + "$link": "bafytest123" 83 + }, 84 + "mimeType": "image/png", 85 + "size": 1024 86 + }, 87 + "alt": "Test image", 88 + "aspectRatio": { 89 + "$type": "pub.leaflet.blocks.image#aspectRatio", 90 + "width": 800, 91 + "height": 600 92 + } 93 + } 94 + }` 95 + 96 + var bw BlockWrap 97 + err := json.Unmarshal([]byte(jsonData), &bw) 98 + shared.AssertNoError(t, err, "unmarshal should succeed") 99 + 100 + block, ok := bw.Block.(ImageBlock) 101 + shared.AssertTrue(t, ok, "block should be ImageBlock") 102 + shared.AssertEqual(t, TypeImageBlock, block.Type, "block type should match") 103 + shared.AssertEqual(t, "Test image", block.Alt, "alt text should match") 104 + shared.AssertEqual(t, 800, block.AspectRatio.Width, "width should match") 105 + shared.AssertEqual(t, 600, block.AspectRatio.Height, "height should match") 106 + shared.AssertEqual(t, "image/png", block.Image.MimeType, "mime type should match") 107 + shared.AssertEqual(t, 1024, block.Image.Size, "size should match") 108 + }) 109 + 110 + t.Run("unmarshals blockquote block", func(t *testing.T) { 111 + jsonData := `{ 112 + "$type": "pub.leaflet.pages.linearDocument#block", 113 + "block": { 114 + "$type": "pub.leaflet.blocks.blockquote", 115 + "plaintext": "This is a quote" 116 + } 117 + }` 118 + 119 + var bw BlockWrap 120 + err := json.Unmarshal([]byte(jsonData), &bw) 121 + shared.AssertNoError(t, err, "unmarshal should succeed") 122 + 123 + block, ok := bw.Block.(BlockquoteBlock) 124 + shared.AssertTrue(t, ok, "block should be BlockquoteBlock") 125 + shared.AssertEqual(t, TypeBlockquoteBlock, block.Type, "block type should match") 126 + shared.AssertEqual(t, "This is a quote", block.Plaintext, "plaintext should match") 127 + }) 128 + 129 + t.Run("unmarshals unordered list block", func(t *testing.T) { 130 + jsonData := `{ 131 + "$type": "pub.leaflet.pages.linearDocument#block", 132 + "block": { 133 + "$type": "pub.leaflet.blocks.unorderedList", 134 + "children": [ 135 + { 136 + "$type": "pub.leaflet.blocks.unorderedList#listItem", 137 + "content": { 138 + "$type": "pub.leaflet.blocks.text", 139 + "plaintext": "First item" 140 + } 141 + } 142 + ] 143 + } 144 + }` 145 + 146 + var bw BlockWrap 147 + err := json.Unmarshal([]byte(jsonData), &bw) 148 + shared.AssertNoError(t, err, "unmarshal should succeed") 149 + 150 + block, ok := bw.Block.(UnorderedListBlock) 151 + shared.AssertTrue(t, ok, "block should be UnorderedListBlock") 152 + shared.AssertEqual(t, TypeUnorderedListBlock, block.Type, "block type should match") 153 + shared.AssertEqual(t, 1, len(block.Children), "should have 1 child") 154 + }) 155 + 156 + t.Run("unmarshals horizontal rule block", func(t *testing.T) { 157 + jsonData := `{ 158 + "$type": "pub.leaflet.pages.linearDocument#block", 159 + "block": { 160 + "$type": "pub.leaflet.blocks.horizontalRule" 161 + } 162 + }` 163 + 164 + var bw BlockWrap 165 + err := json.Unmarshal([]byte(jsonData), &bw) 166 + shared.AssertNoError(t, err, "unmarshal should succeed") 167 + 168 + block, ok := bw.Block.(HorizontalRuleBlock) 169 + shared.AssertTrue(t, ok, "block should be HorizontalRuleBlock") 170 + shared.AssertEqual(t, TypeHorizontalRuleBlock, block.Type, "block type should match") 171 + }) 172 + 173 + t.Run("unmarshals unknown block type as map", func(t *testing.T) { 174 + jsonData := `{ 175 + "$type": "pub.leaflet.pages.linearDocument#block", 176 + "block": { 177 + "$type": "pub.leaflet.blocks.unknown", 178 + "customField": "value" 179 + } 180 + }` 181 + 182 + var bw BlockWrap 183 + err := json.Unmarshal([]byte(jsonData), &bw) 184 + shared.AssertNoError(t, err, "unmarshal should succeed") 185 + 186 + block, ok := bw.Block.(map[string]any) 187 + shared.AssertTrue(t, ok, "block should be map for unknown type") 188 + shared.AssertEqual(t, "pub.leaflet.blocks.unknown", block["$type"], "type should be preserved") 189 + }) 190 + 191 + t.Run("handles block with alignment", func(t *testing.T) { 192 + jsonData := `{ 193 + "$type": "pub.leaflet.pages.linearDocument#block", 194 + "block": { 195 + "$type": "pub.leaflet.blocks.text", 196 + "plaintext": "Centered text" 197 + }, 198 + "alignment": "#textAlignCenter" 199 + }` 200 + 201 + var bw BlockWrap 202 + err := json.Unmarshal([]byte(jsonData), &bw) 203 + shared.AssertNoError(t, err, "unmarshal should succeed") 204 + shared.AssertEqual(t, "#textAlignCenter", bw.Alignment, "alignment should match") 205 + }) 206 + 207 + t.Run("returns error for invalid JSON", func(t *testing.T) { 208 + jsonData := `{invalid json` 209 + 210 + var bw BlockWrap 211 + err := json.Unmarshal([]byte(jsonData), &bw) 212 + shared.AssertError(t, err, "invalid JSON should return error") 213 + }) 214 + 215 + t.Run("returns error for malformed block", func(t *testing.T) { 216 + jsonData := `{ 217 + "$type": "pub.leaflet.pages.linearDocument#block", 218 + "block": "not an object" 219 + }` 220 + 221 + var bw BlockWrap 222 + err := json.Unmarshal([]byte(jsonData), &bw) 223 + shared.AssertError(t, err, "malformed block should return error") 224 + }) 225 + }) 226 + } 227 + 228 + func TestListItem(t *testing.T) { 229 + t.Run("UnmarshalJSON", func(t *testing.T) { 230 + t.Run("unmarshals text block content", func(t *testing.T) { 231 + jsonData := `{ 232 + "$type": "pub.leaflet.blocks.unorderedList#listItem", 233 + "content": { 234 + "$type": "pub.leaflet.blocks.text", 235 + "plaintext": "List item text" 236 + } 237 + }` 238 + 239 + var li ListItem 240 + err := json.Unmarshal([]byte(jsonData), &li) 241 + shared.AssertNoError(t, err, "unmarshal should succeed") 242 + shared.AssertEqual(t, TypeListItem, li.Type, "type should match") 243 + 244 + content, ok := li.Content.(TextBlock) 245 + shared.AssertTrue(t, ok, "content should be TextBlock") 246 + shared.AssertEqual(t, "List item text", content.Plaintext, "plaintext should match") 247 + }) 248 + 249 + t.Run("unmarshals header block content", func(t *testing.T) { 250 + jsonData := `{ 251 + "$type": "pub.leaflet.blocks.unorderedList#listItem", 252 + "content": { 253 + "$type": "pub.leaflet.blocks.header", 254 + "level": 3, 255 + "plaintext": "List header" 256 + } 257 + }` 258 + 259 + var li ListItem 260 + err := json.Unmarshal([]byte(jsonData), &li) 261 + shared.AssertNoError(t, err, "unmarshal should succeed") 262 + 263 + content, ok := li.Content.(HeaderBlock) 264 + shared.AssertTrue(t, ok, "content should be HeaderBlock") 265 + shared.AssertEqual(t, 3, content.Level, "level should match") 266 + }) 267 + 268 + t.Run("unmarshals image block content", func(t *testing.T) { 269 + jsonData := `{ 270 + "$type": "pub.leaflet.blocks.unorderedList#listItem", 271 + "content": { 272 + "$type": "pub.leaflet.blocks.image", 273 + "image": { 274 + "$type": "blob", 275 + "ref": {"$link": "cid123"}, 276 + "mimeType": "image/jpeg", 277 + "size": 2048 278 + }, 279 + "alt": "List image", 280 + "aspectRatio": { 281 + "$type": "pub.leaflet.blocks.image#aspectRatio", 282 + "width": 400, 283 + "height": 300 284 + } 285 + } 286 + }` 287 + 288 + var li ListItem 289 + err := json.Unmarshal([]byte(jsonData), &li) 290 + shared.AssertNoError(t, err, "unmarshal should succeed") 291 + 292 + content, ok := li.Content.(ImageBlock) 293 + shared.AssertTrue(t, ok, "content should be ImageBlock") 294 + shared.AssertEqual(t, "List image", content.Alt, "alt should match") 295 + }) 296 + 297 + t.Run("unmarshals nested list items", func(t *testing.T) { 298 + jsonData := `{ 299 + "$type": "pub.leaflet.blocks.unorderedList#listItem", 300 + "content": { 301 + "$type": "pub.leaflet.blocks.text", 302 + "plaintext": "Parent item" 303 + }, 304 + "children": [ 305 + { 306 + "$type": "pub.leaflet.blocks.unorderedList#listItem", 307 + "content": { 308 + "$type": "pub.leaflet.blocks.text", 309 + "plaintext": "Child item" 310 + } 311 + } 312 + ] 313 + }` 314 + 315 + var li ListItem 316 + err := json.Unmarshal([]byte(jsonData), &li) 317 + shared.AssertNoError(t, err, "unmarshal should succeed") 318 + shared.AssertEqual(t, 1, len(li.Children), "should have 1 child") 319 + 320 + childContent, ok := li.Children[0].Content.(TextBlock) 321 + shared.AssertTrue(t, ok, "child content should be TextBlock") 322 + shared.AssertEqual(t, "Child item", childContent.Plaintext, "child plaintext should match") 323 + }) 324 + 325 + t.Run("unmarshals unknown content type as map", func(t *testing.T) { 326 + jsonData := `{ 327 + "$type": "pub.leaflet.blocks.unorderedList#listItem", 328 + "content": { 329 + "$type": "pub.leaflet.blocks.custom", 330 + "data": "value" 331 + } 332 + }` 333 + 334 + var li ListItem 335 + err := json.Unmarshal([]byte(jsonData), &li) 336 + shared.AssertNoError(t, err, "unmarshal should succeed") 337 + 338 + content, ok := li.Content.(map[string]any) 339 + shared.AssertTrue(t, ok, "unknown content should be map") 340 + shared.AssertEqual(t, "pub.leaflet.blocks.custom", content["$type"], "type should be preserved") 341 + }) 342 + 343 + t.Run("returns error for invalid JSON", func(t *testing.T) { 344 + jsonData := `{invalid` 345 + 346 + var li ListItem 347 + err := json.Unmarshal([]byte(jsonData), &li) 348 + shared.AssertError(t, err, "invalid JSON should return error") 349 + }) 350 + }) 351 + } 352 + 353 + func TestFacet(t *testing.T) { 354 + t.Run("UnmarshalJSON", func(t *testing.T) { 355 + t.Run("unmarshals bold facet", func(t *testing.T) { 356 + jsonData := `{ 357 + "$type": "pub.leaflet.richtext.facet", 358 + "index": { 359 + "$type": "pub.leaflet.richtext.facet#byteSlice", 360 + "byteStart": 0, 361 + "byteEnd": 5 362 + }, 363 + "features": [ 364 + { 365 + "$type": "pub.leaflet.richtext.facet#bold" 366 + } 367 + ] 368 + }` 369 + 370 + var f Facet 371 + err := json.Unmarshal([]byte(jsonData), &f) 372 + shared.AssertNoError(t, err, "unmarshal should succeed") 373 + shared.AssertEqual(t, TypeFacet, f.Type, "type should match") 374 + shared.AssertEqual(t, 0, f.Index.ByteStart, "byte start should match") 375 + shared.AssertEqual(t, 5, f.Index.ByteEnd, "byte end should match") 376 + shared.AssertEqual(t, 1, len(f.Features), "should have 1 feature") 377 + 378 + bold, ok := f.Features[0].(FacetBold) 379 + shared.AssertTrue(t, ok, "feature should be FacetBold") 380 + shared.AssertEqual(t, TypeFacetBold, bold.GetFacetType(), "facet type should match") 381 + }) 382 + 383 + t.Run("unmarshals italic facet", func(t *testing.T) { 384 + jsonData := `{ 385 + "$type": "pub.leaflet.richtext.facet", 386 + "index": { 387 + "$type": "pub.leaflet.richtext.facet#byteSlice", 388 + "byteStart": 5, 389 + "byteEnd": 10 390 + }, 391 + "features": [ 392 + { 393 + "$type": "pub.leaflet.richtext.facet#italic" 394 + } 395 + ] 396 + }` 397 + 398 + var f Facet 399 + err := json.Unmarshal([]byte(jsonData), &f) 400 + shared.AssertNoError(t, err, "unmarshal should succeed") 401 + 402 + italic, ok := f.Features[0].(FacetItalic) 403 + shared.AssertTrue(t, ok, "feature should be FacetItalic") 404 + shared.AssertEqual(t, TypeFacetItalic, italic.GetFacetType(), "facet type should match") 405 + }) 406 + 407 + t.Run("unmarshals code facet", func(t *testing.T) { 408 + jsonData := `{ 409 + "$type": "pub.leaflet.richtext.facet", 410 + "index": { 411 + "$type": "pub.leaflet.richtext.facet#byteSlice", 412 + "byteStart": 0, 413 + "byteEnd": 10 414 + }, 415 + "features": [ 416 + { 417 + "$type": "pub.leaflet.richtext.facet#code" 418 + } 419 + ] 420 + }` 421 + 422 + var f Facet 423 + err := json.Unmarshal([]byte(jsonData), &f) 424 + shared.AssertNoError(t, err, "unmarshal should succeed") 425 + 426 + code, ok := f.Features[0].(FacetCode) 427 + shared.AssertTrue(t, ok, "feature should be FacetCode") 428 + shared.AssertEqual(t, TypeFacetCode, code.GetFacetType(), "facet type should match") 429 + }) 430 + 431 + t.Run("unmarshals link facet", func(t *testing.T) { 432 + jsonData := `{ 433 + "$type": "pub.leaflet.richtext.facet", 434 + "index": { 435 + "$type": "pub.leaflet.richtext.facet#byteSlice", 436 + "byteStart": 0, 437 + "byteEnd": 15 438 + }, 439 + "features": [ 440 + { 441 + "$type": "pub.leaflet.richtext.facet#link", 442 + "uri": "https://example.com" 443 + } 444 + ] 445 + }` 446 + 447 + var f Facet 448 + err := json.Unmarshal([]byte(jsonData), &f) 449 + shared.AssertNoError(t, err, "unmarshal should succeed") 450 + 451 + link, ok := f.Features[0].(FacetLink) 452 + shared.AssertTrue(t, ok, "feature should be FacetLink") 453 + shared.AssertEqual(t, TypeFacetLink, link.GetFacetType(), "facet type should match") 454 + shared.AssertEqual(t, "https://example.com", link.URI, "URI should match") 455 + }) 456 + 457 + t.Run("unmarshals strikethrough facet", func(t *testing.T) { 458 + jsonData := `{ 459 + "$type": "pub.leaflet.richtext.facet", 460 + "index": { 461 + "$type": "pub.leaflet.richtext.facet#byteSlice", 462 + "byteStart": 0, 463 + "byteEnd": 8 464 + }, 465 + "features": [ 466 + { 467 + "$type": "pub.leaflet.richtext.facet#strikethrough" 468 + } 469 + ] 470 + }` 471 + 472 + var f Facet 473 + err := json.Unmarshal([]byte(jsonData), &f) 474 + shared.AssertNoError(t, err, "unmarshal should succeed") 475 + 476 + strike, ok := f.Features[0].(FacetStrikethrough) 477 + shared.AssertTrue(t, ok, "feature should be FacetStrikethrough") 478 + shared.AssertEqual(t, TypeFacetStrike, strike.GetFacetType(), "facet type should match") 479 + }) 480 + 481 + t.Run("unmarshals underline facet", func(t *testing.T) { 482 + jsonData := `{ 483 + "$type": "pub.leaflet.richtext.facet", 484 + "index": { 485 + "$type": "pub.leaflet.richtext.facet#byteSlice", 486 + "byteStart": 0, 487 + "byteEnd": 12 488 + }, 489 + "features": [ 490 + { 491 + "$type": "pub.leaflet.richtext.facet#underline" 492 + } 493 + ] 494 + }` 495 + 496 + var f Facet 497 + err := json.Unmarshal([]byte(jsonData), &f) 498 + shared.AssertNoError(t, err, "unmarshal should succeed") 499 + 500 + underline, ok := f.Features[0].(FacetUnderline) 501 + shared.AssertTrue(t, ok, "feature should be FacetUnderline") 502 + shared.AssertEqual(t, TypeFacetUnderline, underline.GetFacetType(), "facet type should match") 503 + }) 504 + 505 + t.Run("unmarshals highlight facet", func(t *testing.T) { 506 + jsonData := `{ 507 + "$type": "pub.leaflet.richtext.facet", 508 + "index": { 509 + "$type": "pub.leaflet.richtext.facet#byteSlice", 510 + "byteStart": 5, 511 + "byteEnd": 15 512 + }, 513 + "features": [ 514 + { 515 + "$type": "pub.leaflet.richtext.facet#highlight" 516 + } 517 + ] 518 + }` 519 + 520 + var f Facet 521 + err := json.Unmarshal([]byte(jsonData), &f) 522 + shared.AssertNoError(t, err, "unmarshal should succeed") 523 + 524 + highlight, ok := f.Features[0].(FacetHighlight) 525 + shared.AssertTrue(t, ok, "feature should be FacetHighlight") 526 + shared.AssertEqual(t, TypeFacetHighlight, highlight.GetFacetType(), "facet type should match") 527 + }) 528 + 529 + t.Run("unmarshals multiple features", func(t *testing.T) { 530 + jsonData := `{ 531 + "$type": "pub.leaflet.richtext.facet", 532 + "index": { 533 + "$type": "pub.leaflet.richtext.facet#byteSlice", 534 + "byteStart": 0, 535 + "byteEnd": 10 536 + }, 537 + "features": [ 538 + { 539 + "$type": "pub.leaflet.richtext.facet#bold" 540 + }, 541 + { 542 + "$type": "pub.leaflet.richtext.facet#italic" 543 + }, 544 + { 545 + "$type": "pub.leaflet.richtext.facet#link", 546 + "uri": "https://test.com" 547 + } 548 + ] 549 + }` 550 + 551 + var f Facet 552 + err := json.Unmarshal([]byte(jsonData), &f) 553 + shared.AssertNoError(t, err, "unmarshal should succeed") 554 + shared.AssertEqual(t, 3, len(f.Features), "should have 3 features") 555 + 556 + _, isBold := f.Features[0].(FacetBold) 557 + shared.AssertTrue(t, isBold, "first feature should be bold") 558 + 559 + _, isItalic := f.Features[1].(FacetItalic) 560 + shared.AssertTrue(t, isItalic, "second feature should be italic") 561 + 562 + link, isLink := f.Features[2].(FacetLink) 563 + shared.AssertTrue(t, isLink, "third feature should be link") 564 + shared.AssertEqual(t, "https://test.com", link.URI, "link URI should match") 565 + }) 566 + 567 + t.Run("skips unknown feature types", func(t *testing.T) { 568 + jsonData := `{ 569 + "$type": "pub.leaflet.richtext.facet", 570 + "index": { 571 + "$type": "pub.leaflet.richtext.facet#byteSlice", 572 + "byteStart": 0, 573 + "byteEnd": 10 574 + }, 575 + "features": [ 576 + { 577 + "$type": "pub.leaflet.richtext.facet#bold" 578 + }, 579 + { 580 + "$type": "pub.leaflet.richtext.facet#unknown" 581 + }, 582 + { 583 + "$type": "pub.leaflet.richtext.facet#italic" 584 + } 585 + ] 586 + }` 587 + 588 + var f Facet 589 + err := json.Unmarshal([]byte(jsonData), &f) 590 + shared.AssertNoError(t, err, "unmarshal should succeed") 591 + shared.AssertEqual(t, 2, len(f.Features), "unknown features should be skipped") 592 + }) 593 + 594 + t.Run("returns error for invalid JSON", func(t *testing.T) { 595 + jsonData := `{invalid` 596 + 597 + var f Facet 598 + err := json.Unmarshal([]byte(jsonData), &f) 599 + shared.AssertError(t, err, "invalid JSON should return error") 600 + }) 601 + 602 + t.Run("returns error for malformed feature", func(t *testing.T) { 603 + jsonData := `{ 604 + "$type": "pub.leaflet.richtext.facet", 605 + "index": { 606 + "$type": "pub.leaflet.richtext.facet#byteSlice", 607 + "byteStart": 0, 608 + "byteEnd": 10 609 + }, 610 + "features": [ 611 + "not an object" 612 + ] 613 + }` 614 + 615 + var f Facet 616 + err := json.Unmarshal([]byte(jsonData), &f) 617 + shared.AssertError(t, err, "malformed feature should return error") 618 + }) 619 + }) 620 + } 621 + 622 + func TestDocument(t *testing.T) { 623 + t.Run("marshals and unmarshals correctly", func(t *testing.T) { 624 + doc := Document{ 625 + Type: TypeDocument, 626 + Author: "did:plc:test123", 627 + Title: "Test Document", 628 + Description: "A test document", 629 + PublishedAt: "2024-01-01T00:00:00Z", 630 + Publication: "at://did:plc:test123/pub.leaflet.publication/rkey", 631 + Pages: []LinearDocument{ 632 + { 633 + Type: TypeLinearDocument, 634 + ID: "page1", 635 + Blocks: []BlockWrap{ 636 + { 637 + Type: TypeBlock, 638 + Block: TextBlock{ 639 + Type: TypeTextBlock, 640 + Plaintext: "Test content", 641 + }, 642 + }, 643 + }, 644 + }, 645 + }, 646 + } 647 + 648 + data, err := json.Marshal(doc) 649 + shared.AssertNoError(t, err, "marshal should succeed") 650 + 651 + var decoded Document 652 + err = json.Unmarshal(data, &decoded) 653 + shared.AssertNoError(t, err, "unmarshal should succeed") 654 + shared.AssertEqual(t, doc.Title, decoded.Title, "title should match") 655 + shared.AssertEqual(t, doc.Author, decoded.Author, "author should match") 656 + shared.AssertEqual(t, 1, len(decoded.Pages), "should have 1 page") 657 + }) 658 + } 659 + 660 + func TestLinearDocument(t *testing.T) { 661 + t.Run("marshals with multiple block types", func(t *testing.T) { 662 + ld := LinearDocument{ 663 + Type: TypeLinearDocument, 664 + ID: "page1", 665 + Blocks: []BlockWrap{ 666 + { 667 + Type: TypeBlock, 668 + Block: TextBlock{ 669 + Type: TypeTextBlock, 670 + Plaintext: "Text", 671 + }, 672 + }, 673 + { 674 + Type: TypeBlock, 675 + Block: HeaderBlock{ 676 + Type: TypeHeaderBlock, 677 + Level: 1, 678 + Plaintext: "Header", 679 + }, 680 + }, 681 + }, 682 + } 683 + 684 + data, err := json.Marshal(ld) 685 + shared.AssertNoError(t, err, "marshal should succeed") 686 + 687 + var decoded LinearDocument 688 + err = json.Unmarshal(data, &decoded) 689 + shared.AssertNoError(t, err, "unmarshal should succeed") 690 + shared.AssertEqual(t, 2, len(decoded.Blocks), "should have 2 blocks") 691 + }) 692 + } 693 + 694 + func TestFacetFeatures(t *testing.T) { 695 + t.Run("GetFacetType", func(t *testing.T) { 696 + t.Run("returns correct type for bold", func(t *testing.T) { 697 + bold := FacetBold{Type: TypeFacetBold} 698 + shared.AssertEqual(t, TypeFacetBold, bold.GetFacetType(), "type should match") 699 + }) 700 + 701 + t.Run("returns correct type for italic", func(t *testing.T) { 702 + italic := FacetItalic{Type: TypeFacetItalic} 703 + shared.AssertEqual(t, TypeFacetItalic, italic.GetFacetType(), "type should match") 704 + }) 705 + 706 + t.Run("returns correct type for code", func(t *testing.T) { 707 + code := FacetCode{Type: TypeFacetCode} 708 + shared.AssertEqual(t, TypeFacetCode, code.GetFacetType(), "type should match") 709 + }) 710 + 711 + t.Run("returns correct type for link", func(t *testing.T) { 712 + link := FacetLink{Type: TypeFacetLink, URI: "https://example.com"} 713 + shared.AssertEqual(t, TypeFacetLink, link.GetFacetType(), "type should match") 714 + }) 715 + 716 + t.Run("returns correct type for strikethrough", func(t *testing.T) { 717 + strike := FacetStrikethrough{Type: TypeFacetStrike} 718 + shared.AssertEqual(t, TypeFacetStrike, strike.GetFacetType(), "type should match") 719 + }) 720 + 721 + t.Run("returns correct type for underline", func(t *testing.T) { 722 + underline := FacetUnderline{Type: TypeFacetUnderline} 723 + shared.AssertEqual(t, TypeFacetUnderline, underline.GetFacetType(), "type should match") 724 + }) 725 + 726 + t.Run("returns correct type for highlight", func(t *testing.T) { 727 + highlight := FacetHighlight{Type: TypeFacetHighlight} 728 + shared.AssertEqual(t, TypeFacetHighlight, highlight.GetFacetType(), "type should match") 729 + }) 730 + }) 731 + } 732 + 733 + func TestBlob(t *testing.T) { 734 + t.Run("marshals and unmarshals correctly", func(t *testing.T) { 735 + blob := Blob{ 736 + Type: TypeBlob, 737 + Ref: CID{ 738 + Link: "bafytest123", 739 + }, 740 + MimeType: "image/png", 741 + Size: 4096, 742 + } 743 + 744 + data, err := json.Marshal(blob) 745 + shared.AssertNoError(t, err, "marshal should succeed") 746 + 747 + var decoded Blob 748 + err = json.Unmarshal(data, &decoded) 749 + shared.AssertNoError(t, err, "unmarshal should succeed") 750 + shared.AssertEqual(t, blob.MimeType, decoded.MimeType, "mime type should match") 751 + shared.AssertEqual(t, blob.Size, decoded.Size, "size should match") 752 + shared.AssertEqual(t, blob.Ref.Link, decoded.Ref.Link, "CID link should match") 753 + }) 754 + } 755 + 756 + func TestPublication(t *testing.T) { 757 + t.Run("marshals and unmarshals correctly", func(t *testing.T) { 758 + pub := Publication{ 759 + Type: TypePublication, 760 + Name: "Test Publication", 761 + Description: "A test publication", 762 + } 763 + 764 + data, err := json.Marshal(pub) 765 + shared.AssertNoError(t, err, "marshal should succeed") 766 + 767 + var decoded Publication 768 + err = json.Unmarshal(data, &decoded) 769 + shared.AssertNoError(t, err, "unmarshal should succeed") 770 + shared.AssertEqual(t, pub.Name, decoded.Name, "name should match") 771 + shared.AssertEqual(t, pub.Description, decoded.Description, "description should match") 772 + }) 773 + } 774 + 775 + func TestComplexDocument(t *testing.T) { 776 + t.Run("unmarshals complex nested document", func(t *testing.T) { 777 + jsonData := `{ 778 + "$type": "pub.leaflet.document", 779 + "author": "did:plc:abc123", 780 + "title": "Complex Document", 781 + "description": "Testing complex structures", 782 + "publishedAt": "2024-01-15T10:30:00Z", 783 + "publication": "at://did:plc:abc123/pub.leaflet.publication/xyz", 784 + "pages": [ 785 + { 786 + "$type": "pub.leaflet.pages.linearDocument", 787 + "id": "page1", 788 + "blocks": [ 789 + { 790 + "$type": "pub.leaflet.pages.linearDocument#block", 791 + "block": { 792 + "$type": "pub.leaflet.blocks.header", 793 + "level": 1, 794 + "plaintext": "Introduction", 795 + "facets": [ 796 + { 797 + "$type": "pub.leaflet.richtext.facet", 798 + "index": { 799 + "$type": "pub.leaflet.richtext.facet#byteSlice", 800 + "byteStart": 0, 801 + "byteEnd": 12 802 + }, 803 + "features": [ 804 + { 805 + "$type": "pub.leaflet.richtext.facet#bold" 806 + } 807 + ] 808 + } 809 + ] 810 + } 811 + }, 812 + { 813 + "$type": "pub.leaflet.pages.linearDocument#block", 814 + "block": { 815 + "$type": "pub.leaflet.blocks.text", 816 + "plaintext": "This is a link to example", 817 + "facets": [ 818 + { 819 + "$type": "pub.leaflet.richtext.facet", 820 + "index": { 821 + "$type": "pub.leaflet.richtext.facet#byteSlice", 822 + "byteStart": 10, 823 + "byteEnd": 14 824 + }, 825 + "features": [ 826 + { 827 + "$type": "pub.leaflet.richtext.facet#link", 828 + "uri": "https://example.com" 829 + } 830 + ] 831 + } 832 + ] 833 + } 834 + }, 835 + { 836 + "$type": "pub.leaflet.pages.linearDocument#block", 837 + "block": { 838 + "$type": "pub.leaflet.blocks.unorderedList", 839 + "children": [ 840 + { 841 + "$type": "pub.leaflet.blocks.unorderedList#listItem", 842 + "content": { 843 + "$type": "pub.leaflet.blocks.text", 844 + "plaintext": "First item" 845 + }, 846 + "children": [ 847 + { 848 + "$type": "pub.leaflet.blocks.unorderedList#listItem", 849 + "content": { 850 + "$type": "pub.leaflet.blocks.text", 851 + "plaintext": "Nested item" 852 + } 853 + } 854 + ] 855 + } 856 + ] 857 + } 858 + }, 859 + { 860 + "$type": "pub.leaflet.pages.linearDocument#block", 861 + "block": { 862 + "$type": "pub.leaflet.blocks.horizontalRule" 863 + } 864 + } 865 + ] 866 + } 867 + ] 868 + }` 869 + 870 + var doc Document 871 + err := json.Unmarshal([]byte(jsonData), &doc) 872 + shared.AssertNoError(t, err, "unmarshal should succeed") 873 + shared.AssertEqual(t, TypeDocument, doc.Type, "type should match") 874 + shared.AssertEqual(t, "Complex Document", doc.Title, "title should match") 875 + shared.AssertEqual(t, 1, len(doc.Pages), "should have 1 page") 876 + shared.AssertEqual(t, 4, len(doc.Pages[0].Blocks), "should have 4 blocks") 877 + 878 + headerBlock, ok := doc.Pages[0].Blocks[0].Block.(HeaderBlock) 879 + shared.AssertTrue(t, ok, "first block should be HeaderBlock") 880 + shared.AssertEqual(t, 1, headerBlock.Level, "header level should be 1") 881 + shared.AssertEqual(t, 1, len(headerBlock.Facets), "header should have 1 facet") 882 + 883 + textBlock, ok := doc.Pages[0].Blocks[1].Block.(TextBlock) 884 + shared.AssertTrue(t, ok, "second block should be TextBlock") 885 + shared.AssertEqual(t, 1, len(textBlock.Facets), "text should have 1 facet") 886 + link, ok := textBlock.Facets[0].Features[0].(FacetLink) 887 + shared.AssertTrue(t, ok, "facet feature should be link") 888 + shared.AssertEqual(t, "https://example.com", link.URI, "link URI should match") 889 + 890 + listBlock, ok := doc.Pages[0].Blocks[2].Block.(UnorderedListBlock) 891 + shared.AssertTrue(t, ok, "third block should be UnorderedListBlock") 892 + shared.AssertEqual(t, 1, len(listBlock.Children), "list should have 1 child") 893 + shared.AssertEqual(t, 1, len(listBlock.Children[0].Children), "first item should have 1 nested child") 894 + 895 + hrBlock, ok := doc.Pages[0].Blocks[3].Block.(HorizontalRuleBlock) 896 + shared.AssertTrue(t, ok, "fourth block should be HorizontalRuleBlock") 897 + shared.AssertEqual(t, TypeHorizontalRuleBlock, hrBlock.Type, "HR type should match") 898 + }) 899 + }
+15
internal/repo/note_repository.go
··· 333 query := "SELECT " + noteColumns + " FROM notes WHERE leaflet_rkey IS NOT NULL ORDER BY modified DESC" 334 return r.queryMany(ctx, query) 335 }
··· 333 query := "SELECT " + noteColumns + " FROM notes WHERE leaflet_rkey IS NOT NULL ORDER BY modified DESC" 334 return r.queryMany(ctx, query) 335 } 336 + 337 + // GetNewestPublication returns the most recently published leaflet note 338 + func (r *NoteRepository) GetNewestPublication(ctx context.Context) (*models.Note, error) { 339 + query := "SELECT " + noteColumns + " FROM notes WHERE leaflet_rkey IS NOT NULL ORDER BY published_at DESC LIMIT 1" 340 + return r.queryOne(ctx, query) 341 + } 342 + 343 + // DeleteAllLeafletNotes removes all notes with leaflet associations 344 + func (r *NoteRepository) DeleteAllLeafletNotes(ctx context.Context) error { 345 + _, err := r.db.ExecContext(ctx, "DELETE FROM notes WHERE leaflet_rkey IS NOT NULL") 346 + if err != nil { 347 + return fmt.Errorf("failed to delete leaflet notes: %w", err) 348 + } 349 + return nil 350 + }
+50
internal/repo/note_repository_test.go
··· 607 } 608 }) 609 610 t.Run("Context Cancellation", func(t *testing.T) { 611 t.Run("GetByLeafletRKey with cancelled context", func(t *testing.T) { 612 _, err := repo.GetByLeafletRKey(NewCanceledContext(), rkey1) ··· 625 626 t.Run("GetLeafletNotes with cancelled context", func(t *testing.T) { 627 _, err := repo.GetLeafletNotes(NewCanceledContext()) 628 AssertCancelledContext(t, err) 629 }) 630 })
··· 607 } 608 }) 609 610 + t.Run("GetNewestPublication returns most recent published note", func(t *testing.T) { 611 + note, err := repo.GetNewestPublication(ctx) 612 + shared.AssertNoError(t, err, "get newest publication") 613 + shared.AssertNotNil(t, note, "should return a note") 614 + shared.AssertFalse(t, note.IsDraft, "newest should not be draft") 615 + shared.AssertNotNil(t, note.PublishedAt, "should have published_at") 616 + }) 617 + 618 + t.Run("GetNewestPublication returns error when no published notes", func(t *testing.T) { 619 + emptyDB := CreateTestDB(t) 620 + emptyRepo := NewNoteRepository(emptyDB) 621 + 622 + _, err := emptyRepo.GetNewestPublication(ctx) 623 + shared.AssertError(t, err, "should error when no published notes exist") 624 + }) 625 + 626 + t.Run("DeleteAllLeafletNotes removes all leaflet notes", func(t *testing.T) { 627 + beforeLeaflet, err := repo.GetLeafletNotes(ctx) 628 + shared.AssertNoError(t, err, "get leaflet notes before delete") 629 + shared.AssertEqual(t, 3, len(beforeLeaflet), "should have 3 leaflet notes before delete") 630 + 631 + allNotes, err := repo.List(ctx, NoteListOptions{}) 632 + shared.AssertNoError(t, err, "get all notes before delete") 633 + shared.AssertEqual(t, 4, len(allNotes), "should have 4 total notes before delete") 634 + 635 + err = repo.DeleteAllLeafletNotes(ctx) 636 + shared.AssertNoError(t, err, "delete all leaflet notes") 637 + 638 + afterLeaflet, err := repo.GetLeafletNotes(ctx) 639 + shared.AssertNoError(t, err, "get leaflet notes after delete") 640 + shared.AssertEqual(t, 0, len(afterLeaflet), "should have 0 leaflet notes after delete") 641 + 642 + remainingNotes, err := repo.List(ctx, NoteListOptions{}) 643 + shared.AssertNoError(t, err, "get remaining notes") 644 + shared.AssertEqual(t, 1, len(remainingNotes), "should have 1 regular note remaining") 645 + if remainingNotes[0].LeafletRKey != nil { 646 + t.Error("remaining note should not have leaflet rkey") 647 + } 648 + }) 649 + 650 t.Run("Context Cancellation", func(t *testing.T) { 651 t.Run("GetByLeafletRKey with cancelled context", func(t *testing.T) { 652 _, err := repo.GetByLeafletRKey(NewCanceledContext(), rkey1) ··· 665 666 t.Run("GetLeafletNotes with cancelled context", func(t *testing.T) { 667 _, err := repo.GetLeafletNotes(NewCanceledContext()) 668 + AssertCancelledContext(t, err) 669 + }) 670 + 671 + t.Run("GetNewestPublication with cancelled context", func(t *testing.T) { 672 + _, err := repo.GetNewestPublication(NewCanceledContext()) 673 + AssertCancelledContext(t, err) 674 + }) 675 + 676 + t.Run("DeleteAllLeafletNotes with cancelled context", func(t *testing.T) { 677 + err := repo.DeleteAllLeafletNotes(NewCanceledContext()) 678 AssertCancelledContext(t, err) 679 }) 680 })
+58 -7
internal/services/atproto.go
··· 19 lexutil "github.com/bluesky-social/indigo/lex/util" 20 "github.com/bluesky-social/indigo/repo" 21 "github.com/bluesky-social/indigo/xrpc" 22 "github.com/ipfs/go-cid" 23 "github.com/stormlightlabs/noteleaf/internal/public" 24 ) ··· 27 type DocumentWithMeta struct { 28 Document public.Document 29 Meta public.DocumentMeta 30 } 31 32 // PublicationWithMeta combines a publication with its metadata ··· 212 var documents []DocumentWithMeta 213 prefix := public.TypeDocument 214 215 err = r.ForEach(ctx, prefix, func(k string, v cid.Cid) error { 216 _, recordBytes, err := r.GetRecordBytes(ctx, k) 217 if err != nil { 218 return fmt.Errorf("failed to get record bytes for %s: %w", k, err) 219 } 220 221 - var doc public.Document 222 - if err := json.Unmarshal(*recordBytes, &doc); err != nil { 223 - return fmt.Errorf("failed to unmarshal document %s: %w", k, err) 224 } 225 226 parts := strings.Split(k, "/") 227 rkey := "" 228 if len(parts) > 0 { 229 rkey = parts[len(parts)-1] 230 } 231 232 uri := fmt.Sprintf("at://%s/%s", s.session.DID, k) ··· 291 } 292 293 uri := fmt.Sprintf("at://%s/%s", s.session.DID, k) 294 - 295 publications = append(publications, PublicationWithMeta{ 296 Publication: pub, 297 RKey: rkey, 298 CID: v.String(), 299 URI: uri, 300 }) 301 - 302 return nil 303 }) 304 305 if err != nil { 306 return nil, fmt.Errorf("failed to iterate over publications: %w", err) 307 } 308 - 309 return publications, nil 310 } 311 ··· 480 MimeType: output.Blob.MimeType, 481 Size: int(output.Blob.Size), 482 } 483 - 484 return blob, nil 485 } 486
··· 19 lexutil "github.com/bluesky-social/indigo/lex/util" 20 "github.com/bluesky-social/indigo/repo" 21 "github.com/bluesky-social/indigo/xrpc" 22 + "github.com/fxamacker/cbor/v2" 23 "github.com/ipfs/go-cid" 24 "github.com/stormlightlabs/noteleaf/internal/public" 25 ) ··· 28 type DocumentWithMeta struct { 29 Document public.Document 30 Meta public.DocumentMeta 31 + } 32 + 33 + // convertCBORToJSONCompatible recursively converts CBOR data structures to JSON-compatible types 34 + // 35 + // This converts map[any]any to map[string]any to allow usage of [json.Marshal] 36 + func convertCBORToJSONCompatible(data any) any { 37 + switch v := data.(type) { 38 + case map[any]any: 39 + result := make(map[string]any, len(v)) 40 + for key, value := range v { 41 + strKey := fmt.Sprintf("%v", key) 42 + result[strKey] = convertCBORToJSONCompatible(value) 43 + } 44 + return result 45 + case map[string]any: 46 + result := make(map[string]any, len(v)) 47 + for key, value := range v { 48 + result[key] = convertCBORToJSONCompatible(value) 49 + } 50 + return result 51 + case []any: 52 + result := make([]any, len(v)) 53 + for i, item := range v { 54 + result[i] = convertCBORToJSONCompatible(item) 55 + } 56 + return result 57 + default: 58 + return v 59 + } 60 } 61 62 // PublicationWithMeta combines a publication with its metadata ··· 242 var documents []DocumentWithMeta 243 prefix := public.TypeDocument 244 245 + documentCount := 0 246 err = r.ForEach(ctx, prefix, func(k string, v cid.Cid) error { 247 + documentCount++ 248 + 249 _, recordBytes, err := r.GetRecordBytes(ctx, k) 250 if err != nil { 251 return fmt.Errorf("failed to get record bytes for %s: %w", k, err) 252 } 253 254 + var cborData any 255 + if err := cbor.Unmarshal(*recordBytes, &cborData); err != nil { 256 + return fmt.Errorf("failed to decode CBOR for document %s: %w", k, err) 257 + } 258 + 259 + jsonCompatible := convertCBORToJSONCompatible(cborData) 260 + 261 + jsonBytes, err := json.MarshalIndent(jsonCompatible, "", " ") 262 + if err != nil { 263 + return fmt.Errorf("failed to convert CBOR to JSON for document %s: %w", k, err) 264 } 265 266 parts := strings.Split(k, "/") 267 rkey := "" 268 if len(parts) > 0 { 269 rkey = parts[len(parts)-1] 270 + } 271 + 272 + var typeCheck public.TypeCheck 273 + 274 + if err := json.Unmarshal(jsonBytes, &typeCheck); err != nil { 275 + return fmt.Errorf("failed to check $type for %s: %w", k, err) 276 + } 277 + 278 + if typeCheck.Type != public.TypeDocument { 279 + return nil 280 + } 281 + 282 + var doc public.Document 283 + if err := json.Unmarshal(jsonBytes, &doc); err != nil { 284 + return fmt.Errorf("failed to unmarshal JSON to Document for %s: %w", k, err) 285 } 286 287 uri := fmt.Sprintf("at://%s/%s", s.session.DID, k) ··· 346 } 347 348 uri := fmt.Sprintf("at://%s/%s", s.session.DID, k) 349 publications = append(publications, PublicationWithMeta{ 350 Publication: pub, 351 RKey: rkey, 352 CID: v.String(), 353 URI: uri, 354 }) 355 return nil 356 }) 357 358 if err != nil { 359 return nil, fmt.Errorf("failed to iterate over publications: %w", err) 360 } 361 return publications, nil 362 } 363 ··· 532 MimeType: output.Blob.MimeType, 533 Size: int(output.Blob.Size), 534 } 535 return blob, nil 536 } 537
+7 -2
internal/shared/test_utilities.go
··· 64 t.Errorf("%s: expected error but got none", msg) 65 return 66 } 67 - if expected != "" && !ContainsString(err.Error(), expected) { 68 - t.Errorf("%s: expected error containing %q, got: %v", msg, expected, err) 69 } 70 } 71
··· 64 t.Errorf("%s: expected error but got none", msg) 65 return 66 } 67 + 68 + if !ContainsString(err.Error(), expected) { 69 + if msg == "" { 70 + t.Errorf("expected error containing %q, got: %v", expected, err) 71 + } else if expected != "" { 72 + t.Errorf("%s: expected error containing %q, got: %v", msg, expected, err) 73 + } 74 } 75 } 76
+41 -17
internal/ui/data_list.go
··· 9 10 "github.com/charmbracelet/bubbles/help" 11 "github.com/charmbracelet/bubbles/key" 12 tea "github.com/charmbracelet/bubbletea" 13 "github.com/charmbracelet/lipgloss" 14 "github.com/stormlightlabs/noteleaf/internal/models" ··· 157 ) 158 159 type dataListModel struct { 160 - items []ListItem 161 - selected int 162 - viewing bool 163 - viewContent string 164 - searching bool 165 - searchQuery string 166 - err error 167 - loading bool 168 - source ListSource 169 - opts DataListOptions 170 - keys DataListKeyMap 171 - help help.Model 172 - showingHelp bool 173 - totalCount int 174 - listOpts ListOptions 175 } 176 177 func (m dataListModel) Init() tea.Cmd { ··· 199 case key.Matches(msg, m.keys.Help): 200 m.showingHelp = true 201 return m, nil 202 } 203 return m, nil 204 } ··· 279 case listViewMsg: 280 m.viewContent = string(msg) 281 m.viewing = true 282 case listErrorMsg: 283 m.err = error(msg) 284 m.loading = false 285 case listCountMsg: 286 m.totalCount = int(msg) 287 } 288 return m, nil 289 } ··· 298 } 299 300 if m.viewing { 301 - s.WriteString(m.viewContent) 302 s.WriteString("\n\n") 303 - s.WriteString(style.Render("Press q/esc/backspace to return to list, ? for help")) 304 return s.String() 305 } 306
··· 9 10 "github.com/charmbracelet/bubbles/help" 11 "github.com/charmbracelet/bubbles/key" 12 + "github.com/charmbracelet/bubbles/viewport" 13 tea "github.com/charmbracelet/bubbletea" 14 "github.com/charmbracelet/lipgloss" 15 "github.com/stormlightlabs/noteleaf/internal/models" ··· 158 ) 159 160 type dataListModel struct { 161 + items []ListItem 162 + selected int 163 + viewing bool 164 + viewContent string 165 + viewViewport viewport.Model 166 + searching bool 167 + searchQuery string 168 + err error 169 + loading bool 170 + source ListSource 171 + opts DataListOptions 172 + keys DataListKeyMap 173 + help help.Model 174 + showingHelp bool 175 + totalCount int 176 + listOpts ListOptions 177 } 178 179 func (m dataListModel) Init() tea.Cmd { ··· 201 case key.Matches(msg, m.keys.Help): 202 m.showingHelp = true 203 return m, nil 204 + case key.Matches(msg, m.keys.Up): 205 + m.viewViewport.ScrollUp(1) 206 + case key.Matches(msg, m.keys.Down): 207 + m.viewViewport.ScrollDown(1) 208 + case msg.String() == "pgup", msg.String() == "b": 209 + m.viewViewport.HalfPageUp() 210 + case msg.String() == "pgdown", msg.String() == "f": 211 + m.viewViewport.HalfPageDown() 212 + case msg.String() == "g", msg.String() == "home": 213 + m.viewViewport.GotoTop() 214 + case msg.String() == "G", msg.String() == "end": 215 + m.viewViewport.GotoBottom() 216 } 217 return m, nil 218 } ··· 293 case listViewMsg: 294 m.viewContent = string(msg) 295 m.viewing = true 296 + m.viewViewport = viewport.New(80, 20) 297 + m.viewViewport.SetContent(m.viewContent) 298 case listErrorMsg: 299 m.err = error(msg) 300 m.loading = false 301 case listCountMsg: 302 m.totalCount = int(msg) 303 + case tea.WindowSizeMsg: 304 + if m.viewing { 305 + headerHeight := 2 306 + footerHeight := 3 307 + verticalMarginHeight := headerHeight + footerHeight 308 + m.viewViewport.Width = msg.Width 309 + m.viewViewport.Height = msg.Height - verticalMarginHeight 310 + } 311 } 312 return m, nil 313 } ··· 322 } 323 324 if m.viewing { 325 + s.WriteString(m.viewViewport.View()) 326 s.WriteString("\n\n") 327 + s.WriteString(style.Render("โ†‘/โ†“/pgup/pgdn: scroll | g/G: top/bottom | q/esc: back | ?: help")) 328 return s.String() 329 } 330
+9 -4
internal/ui/data_list_test.go
··· 11 12 "github.com/charmbracelet/bubbles/help" 13 "github.com/charmbracelet/bubbles/key" 14 tea "github.com/charmbracelet/bubbletea" 15 ) 16 ··· 732 }) 733 734 t.Run("viewing mode", func(t *testing.T) { 735 model := dataListModel{ 736 - viewing: true, 737 - viewContent: "# Test Content\nDetails here", 738 - opts: DataListOptions{}, 739 } 740 741 view := model.View() 742 if !strings.Contains(view, "# Test Content") { 743 t.Error("View content not displayed") 744 } 745 - if !strings.Contains(view, "Press q/esc/backspace to return") { 746 t.Error("Return instructions not displayed") 747 } 748 })
··· 11 12 "github.com/charmbracelet/bubbles/help" 13 "github.com/charmbracelet/bubbles/key" 14 + "github.com/charmbracelet/bubbles/viewport" 15 tea "github.com/charmbracelet/bubbletea" 16 ) 17 ··· 733 }) 734 735 t.Run("viewing mode", func(t *testing.T) { 736 + vp := viewport.New(80, 20) 737 + vp.SetContent("# Test Content\nDetails here") 738 + 739 model := dataListModel{ 740 + viewing: true, 741 + viewContent: "# Test Content\nDetails here", 742 + viewViewport: vp, 743 + opts: DataListOptions{}, 744 } 745 746 view := model.View() 747 if !strings.Contains(view, "# Test Content") { 748 t.Error("View content not displayed") 749 } 750 + if !strings.Contains(view, "q/esc: back") { 751 t.Error("Return instructions not displayed") 752 } 753 })
+279
internal/ui/publication_view.go
···
··· 1 + package ui 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "os" 8 + "strings" 9 + 10 + "github.com/charmbracelet/bubbles/help" 11 + "github.com/charmbracelet/bubbles/key" 12 + "github.com/charmbracelet/bubbles/viewport" 13 + tea "github.com/charmbracelet/bubbletea" 14 + "github.com/charmbracelet/glamour" 15 + "github.com/charmbracelet/lipgloss" 16 + "github.com/stormlightlabs/noteleaf/internal/models" 17 + ) 18 + 19 + // PublicationViewOptions configures the publication view UI behavior 20 + type PublicationViewOptions struct { 21 + Output io.Writer 22 + Input io.Reader 23 + Static bool 24 + Width int 25 + Height int 26 + } 27 + 28 + // PublicationView handles publication viewing UI with pager 29 + type PublicationView struct { 30 + note *models.Note 31 + opts PublicationViewOptions 32 + } 33 + 34 + // NewPublicationView creates a new publication view UI component 35 + func NewPublicationView(note *models.Note, opts PublicationViewOptions) *PublicationView { 36 + if opts.Output == nil { 37 + opts.Output = os.Stdout 38 + } 39 + if opts.Input == nil { 40 + opts.Input = os.Stdin 41 + } 42 + if opts.Width == 0 { 43 + opts.Width = 80 44 + } 45 + if opts.Height == 0 { 46 + opts.Height = 24 47 + } 48 + return &PublicationView{note: note, opts: opts} 49 + } 50 + 51 + // Publication view specific key bindings 52 + type publicationViewKeyMap struct { 53 + Up key.Binding 54 + Down key.Binding 55 + PageUp key.Binding 56 + PageDown key.Binding 57 + Top key.Binding 58 + Bottom key.Binding 59 + Quit key.Binding 60 + Back key.Binding 61 + Help key.Binding 62 + } 63 + 64 + func (k publicationViewKeyMap) ShortHelp() []key.Binding { 65 + return []key.Binding{k.Up, k.Down, k.Back, k.Help, k.Quit} 66 + } 67 + 68 + func (k publicationViewKeyMap) FullHelp() [][]key.Binding { 69 + return [][]key.Binding{ 70 + {k.Up, k.Down, k.PageUp, k.PageDown}, 71 + {k.Top, k.Bottom}, 72 + {k.Help, k.Back, k.Quit}, 73 + } 74 + } 75 + 76 + var publicationViewKeys = publicationViewKeyMap{ 77 + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("โ†‘/k", "scroll up")), 78 + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("โ†“/j", "scroll down")), 79 + PageUp: key.NewBinding(key.WithKeys("pgup", "b"), key.WithHelp("pgup/b", "page up")), 80 + PageDown: key.NewBinding(key.WithKeys("pgdown", "f"), key.WithHelp("pgdown/f", "page down")), 81 + Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home/g", "go to top")), 82 + Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end/G", "go to bottom")), 83 + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), 84 + Back: key.NewBinding(key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back")), 85 + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), 86 + } 87 + 88 + type publicationViewModel struct { 89 + note *models.Note 90 + viewport viewport.Model 91 + keys publicationViewKeyMap 92 + help help.Model 93 + showingHelp bool 94 + opts PublicationViewOptions 95 + ready bool 96 + } 97 + 98 + func (m publicationViewModel) Init() tea.Cmd { 99 + return nil 100 + } 101 + 102 + func (m publicationViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 103 + var cmd tea.Cmd 104 + 105 + switch msg := msg.(type) { 106 + case tea.KeyMsg: 107 + if m.showingHelp { 108 + switch { 109 + case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Help): 110 + m.showingHelp = false 111 + return m, nil 112 + } 113 + return m, nil 114 + } 115 + 116 + switch { 117 + case key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Back): 118 + return m, tea.Quit 119 + case key.Matches(msg, m.keys.Help): 120 + m.showingHelp = true 121 + return m, nil 122 + case key.Matches(msg, m.keys.Up): 123 + m.viewport.ScrollUp(1) 124 + case key.Matches(msg, m.keys.Down): 125 + m.viewport.ScrollDown(1) 126 + case key.Matches(msg, m.keys.PageUp): 127 + m.viewport.HalfPageUp() 128 + case key.Matches(msg, m.keys.PageDown): 129 + m.viewport.HalfPageDown() 130 + case key.Matches(msg, m.keys.Top): 131 + m.viewport.GotoTop() 132 + case key.Matches(msg, m.keys.Bottom): 133 + m.viewport.GotoBottom() 134 + } 135 + 136 + case tea.WindowSizeMsg: 137 + headerHeight := 3 138 + footerHeight := 3 139 + verticalMarginHeight := headerHeight + footerHeight 140 + 141 + if !m.opts.Static { 142 + m.viewport.Width = msg.Width - 2 143 + m.viewport.Height = msg.Height - verticalMarginHeight 144 + } 145 + 146 + if !m.ready { 147 + m.ready = true 148 + } 149 + } 150 + 151 + m.viewport, cmd = m.viewport.Update(msg) 152 + return m, cmd 153 + } 154 + 155 + func (m publicationViewModel) View() string { 156 + if m.showingHelp { 157 + return m.help.View(m.keys) 158 + } 159 + 160 + status := "published" 161 + if m.note.IsDraft { 162 + status = "draft" 163 + } 164 + 165 + title := TitleColorStyle.Render(fmt.Sprintf("%s (%s)", m.note.Title, status)) 166 + content := m.viewport.View() 167 + help := lipgloss.NewStyle().Foreground(lipgloss.Color(Squid.Hex())).Render(m.help.View(m.keys)) 168 + 169 + if !m.ready { 170 + return "\n Initializing..." 171 + } 172 + 173 + return lipgloss.JoinVertical(lipgloss.Left, title, "", content, "", help) 174 + } 175 + 176 + // buildPublicationMarkdown creates the markdown content for rendering 177 + func buildPublicationMarkdown(note *models.Note) string { 178 + var content strings.Builder 179 + 180 + content.WriteString("# " + note.Title + "\n\n") 181 + 182 + status := "published" 183 + if note.IsDraft { 184 + status = "draft" 185 + } 186 + content.WriteString("**Status:** " + status + "\n") 187 + 188 + if note.PublishedAt != nil { 189 + content.WriteString("**Published:** " + note.PublishedAt.Format("2006-01-02 15:04") + "\n") 190 + } 191 + 192 + content.WriteString("**Modified:** " + note.Modified.Format("2006-01-02 15:04") + "\n") 193 + 194 + if note.LeafletRKey != nil { 195 + content.WriteString("**RKey:** `" + *note.LeafletRKey + "`\n") 196 + } 197 + 198 + if note.LeafletCID != nil { 199 + content.WriteString("**CID:** `" + *note.LeafletCID + "`\n") 200 + } 201 + 202 + content.WriteString("\n---\n\n") 203 + 204 + noteContent := strings.TrimSpace(note.Content) 205 + if !strings.HasPrefix(noteContent, "# ") { 206 + content.WriteString(noteContent) 207 + } else { 208 + lines := strings.Split(noteContent, "\n") 209 + if len(lines) > 1 { 210 + content.WriteString(strings.Join(lines[1:], "\n")) 211 + } 212 + } 213 + 214 + return content.String() 215 + } 216 + 217 + // formatPublicationContent renders markdown with glamour for viewport display 218 + func formatPublicationContent(note *models.Note) (string, error) { 219 + markdown := buildPublicationMarkdown(note) 220 + 221 + renderer, err := glamour.NewTermRenderer( 222 + glamour.WithAutoStyle(), 223 + glamour.WithWordWrap(80), 224 + ) 225 + if err != nil { 226 + return markdown, fmt.Errorf("failed to create renderer: %w", err) 227 + } 228 + 229 + rendered, err := renderer.Render(markdown) 230 + if err != nil { 231 + return markdown, fmt.Errorf("failed to render markdown: %w", err) 232 + } 233 + 234 + return rendered, nil 235 + } 236 + 237 + // Show displays the publication in interactive mode with pager 238 + func (pv *PublicationView) Show(ctx context.Context) error { 239 + if pv.opts.Static { 240 + return pv.staticShow(ctx) 241 + } 242 + 243 + content, err := formatPublicationContent(pv.note) 244 + if err != nil { 245 + return err 246 + } 247 + 248 + vp := viewport.New(pv.opts.Width-2, pv.opts.Height-6) 249 + vp.SetContent(content) 250 + 251 + model := publicationViewModel{ 252 + note: pv.note, 253 + viewport: vp, 254 + keys: publicationViewKeys, 255 + help: help.New(), 256 + opts: pv.opts, 257 + } 258 + 259 + program := tea.NewProgram( 260 + model, 261 + tea.WithInput(pv.opts.Input), 262 + tea.WithOutput(pv.opts.Output), 263 + tea.WithAltScreen(), 264 + tea.WithMouseCellMotion(), 265 + ) 266 + 267 + _, err = program.Run() 268 + return err 269 + } 270 + 271 + func (pv *PublicationView) staticShow(context.Context) error { 272 + content, err := formatPublicationContent(pv.note) 273 + if err != nil { 274 + return err 275 + } 276 + 277 + fmt.Fprint(pv.opts.Output, content) 278 + return nil 279 + }
+665
internal/ui/publication_view_test.go
···
··· 1 + package ui 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "strings" 7 + "testing" 8 + "time" 9 + 10 + "github.com/charmbracelet/bubbles/help" 11 + "github.com/charmbracelet/bubbles/viewport" 12 + tea "github.com/charmbracelet/bubbletea" 13 + "github.com/stormlightlabs/noteleaf/internal/models" 14 + ) 15 + 16 + func createMockNote() *models.Note { 17 + now := time.Now() 18 + publishedAt := now.Add(-24 * time.Hour) 19 + rkey := "test-rkey-123" 20 + cid := "test-cid-456" 21 + 22 + return &models.Note{ 23 + ID: 1, 24 + Title: "Test Publication", 25 + Content: "# Test Publication\n\nThis is the content of the test publication.", 26 + Tags: []string{"test", "publication"}, 27 + Archived: false, 28 + Created: now.Add(-48 * time.Hour), 29 + Modified: now.Add(-1 * time.Hour), 30 + LeafletRKey: &rkey, 31 + LeafletCID: &cid, 32 + PublishedAt: &publishedAt, 33 + IsDraft: false, 34 + } 35 + } 36 + 37 + func createDraftNote() *models.Note { 38 + note := createMockNote() 39 + note.IsDraft = true 40 + note.PublishedAt = nil 41 + note.LeafletRKey = nil 42 + note.LeafletCID = nil 43 + return note 44 + } 45 + 46 + func createMinimalNote() *models.Note { 47 + now := time.Now() 48 + return &models.Note{ 49 + ID: 2, 50 + Title: "Minimal Note", 51 + Content: "Simple content without markdown heading.", 52 + Created: now, 53 + Modified: now, 54 + IsDraft: true, 55 + } 56 + } 57 + 58 + func TestPublicationView(t *testing.T) { 59 + t.Run("View Options", func(t *testing.T) { 60 + note := createMockNote() 61 + 62 + t.Run("default options", func(t *testing.T) { 63 + opts := PublicationViewOptions{} 64 + pv := NewPublicationView(note, opts) 65 + 66 + if pv.opts.Output == nil { 67 + t.Error("Output should default to os.Stdout") 68 + } 69 + if pv.opts.Input == nil { 70 + t.Error("Input should default to os.Stdin") 71 + } 72 + if pv.opts.Width != 80 { 73 + t.Errorf("Width should default to 80, got %d", pv.opts.Width) 74 + } 75 + if pv.opts.Height != 24 { 76 + t.Errorf("Height should default to 24, got %d", pv.opts.Height) 77 + } 78 + }) 79 + 80 + t.Run("custom options", func(t *testing.T) { 81 + var buf bytes.Buffer 82 + opts := PublicationViewOptions{ 83 + Output: &buf, 84 + Static: true, 85 + Width: 100, 86 + Height: 30, 87 + } 88 + pv := NewPublicationView(note, opts) 89 + 90 + if pv.opts.Output != &buf { 91 + t.Error("Custom output not set") 92 + } 93 + if !pv.opts.Static { 94 + t.Error("Static mode not set") 95 + } 96 + if pv.opts.Width != 100 { 97 + t.Error("Custom width not set") 98 + } 99 + if pv.opts.Height != 30 { 100 + t.Error("Custom height not set") 101 + } 102 + }) 103 + }) 104 + 105 + t.Run("New", func(t *testing.T) { 106 + note := createMockNote() 107 + 108 + t.Run("creates publication view correctly", func(t *testing.T) { 109 + opts := PublicationViewOptions{Width: 60, Height: 20} 110 + pv := NewPublicationView(note, opts) 111 + 112 + if pv.note != note { 113 + t.Error("Note not set correctly") 114 + } 115 + if pv.opts.Width != 60 { 116 + t.Error("Width not set correctly") 117 + } 118 + if pv.opts.Height != 20 { 119 + t.Error("Height not set correctly") 120 + } 121 + }) 122 + }) 123 + 124 + t.Run("Static Mode", func(t *testing.T) { 125 + t.Run("published note display", func(t *testing.T) { 126 + note := createMockNote() 127 + var buf bytes.Buffer 128 + 129 + pv := NewPublicationView(note, PublicationViewOptions{ 130 + Output: &buf, 131 + Static: true, 132 + }) 133 + 134 + err := pv.Show(context.Background()) 135 + if err != nil { 136 + t.Fatalf("Show failed: %v", err) 137 + } 138 + 139 + output := buf.String() 140 + 141 + if !strings.Contains(output, "Test Publication") { 142 + t.Error("Note title not displayed") 143 + } 144 + if !strings.Contains(output, "published") { 145 + t.Error("Published status not displayed") 146 + } 147 + if !strings.Contains(output, "Published:") { 148 + t.Error("Published date not displayed") 149 + } 150 + if !strings.Contains(output, "Modified:") { 151 + t.Error("Modified date not displayed") 152 + } 153 + if !strings.Contains(output, "RKey:") { 154 + t.Error("RKey not displayed") 155 + } 156 + if !strings.Contains(output, "test-rkey-123") { 157 + t.Error("RKey value not displayed") 158 + } 159 + if !strings.Contains(output, "CID:") { 160 + t.Error("CID not displayed") 161 + } 162 + if !strings.Contains(output, "test-cid-456") { 163 + t.Error("CID value not displayed") 164 + } 165 + if !strings.Contains(output, "This is the content") { 166 + t.Error("Note content not displayed") 167 + } 168 + }) 169 + 170 + t.Run("draft note display", func(t *testing.T) { 171 + note := createDraftNote() 172 + var buf bytes.Buffer 173 + 174 + pv := NewPublicationView(note, PublicationViewOptions{ 175 + Output: &buf, 176 + Static: true, 177 + }) 178 + 179 + err := pv.Show(context.Background()) 180 + if err != nil { 181 + t.Fatalf("Show failed: %v", err) 182 + } 183 + 184 + output := buf.String() 185 + 186 + if !strings.Contains(output, "draft") { 187 + t.Error("Draft status not displayed") 188 + } 189 + if strings.Contains(output, "Published:") { 190 + t.Error("Published date should not be displayed for draft") 191 + } 192 + if strings.Contains(output, "RKey:") { 193 + t.Error("RKey should not be displayed for draft") 194 + } 195 + if strings.Contains(output, "CID:") { 196 + t.Error("CID should not be displayed for draft") 197 + } 198 + }) 199 + 200 + t.Run("minimal note display", func(t *testing.T) { 201 + note := createMinimalNote() 202 + var buf bytes.Buffer 203 + 204 + pv := NewPublicationView(note, PublicationViewOptions{ 205 + Output: &buf, 206 + Static: true, 207 + }) 208 + 209 + err := pv.Show(context.Background()) 210 + if err != nil { 211 + t.Fatalf("Show failed: %v", err) 212 + } 213 + 214 + output := buf.String() 215 + 216 + if !strings.Contains(output, "Minimal Note") { 217 + t.Error("Note title not displayed") 218 + } 219 + if !strings.Contains(output, "Simple content") { 220 + t.Error("Note content not displayed") 221 + } 222 + if !strings.Contains(output, "Modified:") { 223 + t.Error("Modified date not displayed") 224 + } 225 + }) 226 + }) 227 + 228 + t.Run("Build Markdown", func(t *testing.T) { 229 + t.Run("builds markdown for published note", func(t *testing.T) { 230 + note := createMockNote() 231 + markdown := buildPublicationMarkdown(note) 232 + 233 + expectedStrings := []string{ 234 + "# Test Publication", 235 + "**Status:** published", 236 + "**Published:**", 237 + "**Modified:**", 238 + "**RKey:** `test-rkey-123`", 239 + "**CID:** `test-cid-456`", 240 + "---", 241 + "This is the content", 242 + } 243 + 244 + for _, expected := range expectedStrings { 245 + if !strings.Contains(markdown, expected) { 246 + t.Errorf("Expected markdown '%s' not found in output", expected) 247 + } 248 + } 249 + }) 250 + 251 + t.Run("builds markdown for draft note", func(t *testing.T) { 252 + note := createDraftNote() 253 + markdown := buildPublicationMarkdown(note) 254 + 255 + if !strings.Contains(markdown, "**Status:** draft") { 256 + t.Error("Draft status not in markdown") 257 + } 258 + if strings.Contains(markdown, "**Published:**") { 259 + t.Error("Published date should not be in draft markdown") 260 + } 261 + if strings.Contains(markdown, "**RKey:**") { 262 + t.Error("RKey should not be in draft markdown") 263 + } 264 + if strings.Contains(markdown, "**CID:**") { 265 + t.Error("CID should not be in draft markdown") 266 + } 267 + }) 268 + 269 + t.Run("handles content with markdown heading", func(t *testing.T) { 270 + note := createMockNote() 271 + markdown := buildPublicationMarkdown(note) 272 + 273 + headingCount := strings.Count(markdown, "# Test Publication") 274 + if headingCount != 1 { 275 + t.Errorf("Expected 1 heading, found %d (content heading should be stripped)", headingCount) 276 + } 277 + }) 278 + 279 + t.Run("handles content without markdown heading", func(t *testing.T) { 280 + note := createMinimalNote() 281 + markdown := buildPublicationMarkdown(note) 282 + 283 + if !strings.Contains(markdown, "Simple content") { 284 + t.Error("Content without heading should be included as-is") 285 + } 286 + }) 287 + }) 288 + 289 + t.Run("Format Content", func(t *testing.T) { 290 + t.Run("formats content with glamour", func(t *testing.T) { 291 + note := createMockNote() 292 + content, err := formatPublicationContent(note) 293 + 294 + if err != nil { 295 + t.Fatalf("formatPublicationContent failed: %v", err) 296 + } 297 + 298 + if len(content) == 0 { 299 + t.Error("Formatted content should not be empty") 300 + } 301 + }) 302 + 303 + t.Run("includes note title in formatted content", func(t *testing.T) { 304 + note := createMockNote() 305 + content, err := formatPublicationContent(note) 306 + 307 + if err != nil { 308 + t.Fatalf("formatPublicationContent failed: %v", err) 309 + } 310 + 311 + if !strings.Contains(content, "Test Publication") { 312 + t.Error("Formatted content should include note title") 313 + } 314 + }) 315 + }) 316 + 317 + t.Run("Model", func(t *testing.T) { 318 + note := createMockNote() 319 + 320 + t.Run("initial model state", func(t *testing.T) { 321 + model := publicationViewModel{ 322 + note: note, 323 + opts: PublicationViewOptions{Width: 80, Height: 24}, 324 + } 325 + 326 + if model.showingHelp { 327 + t.Error("Initial showingHelp should be false") 328 + } 329 + if model.note != note { 330 + t.Error("Note not set correctly") 331 + } 332 + }) 333 + 334 + t.Run("key handling - help toggle", func(t *testing.T) { 335 + vp := viewport.New(80, 20) 336 + model := publicationViewModel{ 337 + note: note, 338 + viewport: vp, 339 + keys: publicationViewKeys, 340 + help: help.New(), 341 + ready: true, 342 + } 343 + 344 + newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")}) 345 + if m, ok := newModel.(publicationViewModel); ok { 346 + if !m.showingHelp { 347 + t.Error("Help key should show help") 348 + } 349 + } 350 + 351 + model.showingHelp = true 352 + newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")}) 353 + if m, ok := newModel.(publicationViewModel); ok { 354 + if m.showingHelp { 355 + t.Error("Help key should exit help when already showing") 356 + } 357 + } 358 + }) 359 + 360 + t.Run("key handling - quit and back", func(t *testing.T) { 361 + vp := viewport.New(80, 20) 362 + model := publicationViewModel{ 363 + note: note, 364 + viewport: vp, 365 + keys: publicationViewKeys, 366 + help: help.New(), 367 + ready: true, 368 + } 369 + 370 + quitKeys := []string{"q", "esc"} 371 + for _, key := range quitKeys { 372 + _, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) 373 + if cmd == nil { 374 + t.Errorf("Key %s should return quit command", key) 375 + } 376 + } 377 + }) 378 + 379 + t.Run("viewport navigation", func(t *testing.T) { 380 + vp := viewport.New(80, 20) 381 + longContent := strings.Repeat("Line of content\n", 50) 382 + vp.SetContent(longContent) 383 + 384 + model := publicationViewModel{ 385 + note: note, 386 + viewport: vp, 387 + keys: publicationViewKeys, 388 + help: help.New(), 389 + ready: true, 390 + } 391 + 392 + initialOffset := model.viewport.YOffset 393 + 394 + newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) 395 + if m, ok := newModel.(publicationViewModel); ok { 396 + if m.viewport.YOffset <= initialOffset { 397 + t.Error("Down key should scroll viewport down") 398 + } 399 + } 400 + 401 + model.viewport.ScrollDown(5) 402 + initialOffset = model.viewport.YOffset 403 + newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")}) 404 + if m, ok := newModel.(publicationViewModel); ok { 405 + if m.viewport.YOffset >= initialOffset { 406 + t.Error("Up key should scroll viewport up") 407 + } 408 + } 409 + }) 410 + 411 + t.Run("page navigation", func(t *testing.T) { 412 + vp := viewport.New(80, 20) 413 + longContent := strings.Repeat("Line of content\n", 100) 414 + vp.SetContent(longContent) 415 + 416 + model := publicationViewModel{ 417 + note: note, 418 + viewport: vp, 419 + keys: publicationViewKeys, 420 + help: help.New(), 421 + ready: true, 422 + } 423 + 424 + initialOffset := model.viewport.YOffset 425 + 426 + newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")}) 427 + if m, ok := newModel.(publicationViewModel); ok { 428 + if m.viewport.YOffset <= initialOffset { 429 + t.Error("Page down key should scroll viewport down") 430 + } 431 + } 432 + }) 433 + 434 + t.Run("top and bottom navigation", func(t *testing.T) { 435 + vp := viewport.New(80, 20) 436 + longContent := strings.Repeat("Line of content\n", 100) 437 + vp.SetContent(longContent) 438 + 439 + model := publicationViewModel{ 440 + note: note, 441 + viewport: vp, 442 + keys: publicationViewKeys, 443 + help: help.New(), 444 + ready: true, 445 + } 446 + 447 + model.viewport.ScrollDown(50) 448 + 449 + newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")}) 450 + if m, ok := newModel.(publicationViewModel); ok { 451 + if m.viewport.YOffset != 0 { 452 + t.Error("Top key should scroll to top") 453 + } 454 + } 455 + 456 + newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")}) 457 + if m, ok := newModel.(publicationViewModel); ok { 458 + if m.viewport.YOffset == 0 { 459 + t.Error("Bottom key should scroll to bottom") 460 + } 461 + } 462 + }) 463 + 464 + t.Run("window size message handling", func(t *testing.T) { 465 + vp := viewport.New(80, 20) 466 + model := publicationViewModel{ 467 + note: note, 468 + viewport: vp, 469 + keys: publicationViewKeys, 470 + help: help.New(), 471 + opts: PublicationViewOptions{Static: false}, 472 + } 473 + 474 + newModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) 475 + if m, ok := newModel.(publicationViewModel); ok { 476 + if m.viewport.Width != 98 { 477 + t.Errorf("Viewport width should be 98 (100-2), got %d", m.viewport.Width) 478 + } 479 + expectedHeight := 30 - 6 480 + if m.viewport.Height != expectedHeight { 481 + t.Errorf("Viewport height should be %d, got %d", expectedHeight, m.viewport.Height) 482 + } 483 + if !m.ready { 484 + t.Error("Model should be ready after window size message") 485 + } 486 + } 487 + }) 488 + 489 + t.Run("static mode ignores window resize", func(t *testing.T) { 490 + vp := viewport.New(80, 20) 491 + model := publicationViewModel{ 492 + note: note, 493 + viewport: vp, 494 + keys: publicationViewKeys, 495 + help: help.New(), 496 + opts: PublicationViewOptions{Static: true}, 497 + ready: true, 498 + } 499 + 500 + newModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) 501 + if m, ok := newModel.(publicationViewModel); ok { 502 + if m.viewport.Width != 80 { 503 + t.Error("Static mode should not resize viewport width") 504 + } 505 + if m.viewport.Height != 20 { 506 + t.Error("Static mode should not resize viewport height") 507 + } 508 + } 509 + }) 510 + }) 511 + 512 + t.Run("View Model", func(t *testing.T) { 513 + note := createMockNote() 514 + 515 + t.Run("normal view with published note", func(t *testing.T) { 516 + vp := viewport.New(80, 20) 517 + content, _ := formatPublicationContent(note) 518 + vp.SetContent(content) 519 + 520 + model := publicationViewModel{ 521 + note: note, 522 + viewport: vp, 523 + keys: publicationViewKeys, 524 + help: help.New(), 525 + ready: true, 526 + } 527 + 528 + view := model.View() 529 + 530 + if !strings.Contains(view, "Test Publication") { 531 + t.Error("Note title not displayed in view") 532 + } 533 + if !strings.Contains(view, "published") { 534 + t.Error("Published status not displayed in view") 535 + } 536 + }) 537 + 538 + t.Run("normal view with draft note", func(t *testing.T) { 539 + draft := createDraftNote() 540 + vp := viewport.New(80, 20) 541 + content, _ := formatPublicationContent(draft) 542 + vp.SetContent(content) 543 + 544 + model := publicationViewModel{ 545 + note: draft, 546 + viewport: vp, 547 + keys: publicationViewKeys, 548 + help: help.New(), 549 + ready: true, 550 + } 551 + 552 + view := model.View() 553 + 554 + if !strings.Contains(view, "draft") { 555 + t.Error("Draft status not displayed in view") 556 + } 557 + }) 558 + 559 + t.Run("help view", func(t *testing.T) { 560 + vp := viewport.New(80, 20) 561 + model := publicationViewModel{ 562 + note: note, 563 + viewport: vp, 564 + keys: publicationViewKeys, 565 + help: help.New(), 566 + showingHelp: true, 567 + ready: true, 568 + } 569 + 570 + view := model.View() 571 + 572 + if !strings.Contains(view, "scroll") { 573 + t.Error("Help view should contain scroll instructions") 574 + } 575 + }) 576 + 577 + t.Run("initializing view", func(t *testing.T) { 578 + vp := viewport.New(80, 20) 579 + model := publicationViewModel{ 580 + note: note, 581 + viewport: vp, 582 + keys: publicationViewKeys, 583 + help: help.New(), 584 + ready: false, 585 + } 586 + 587 + view := model.View() 588 + 589 + if !strings.Contains(view, "Initializing") { 590 + t.Error("Not ready state should show initializing message") 591 + } 592 + }) 593 + }) 594 + 595 + t.Run("Key Bindings", func(t *testing.T) { 596 + t.Run("short help bindings", func(t *testing.T) { 597 + bindings := publicationViewKeys.ShortHelp() 598 + if len(bindings) != 5 { 599 + t.Errorf("Expected 5 short help bindings, got %d", len(bindings)) 600 + } 601 + }) 602 + 603 + t.Run("full help bindings", func(t *testing.T) { 604 + bindings := publicationViewKeys.FullHelp() 605 + if len(bindings) != 3 { 606 + t.Errorf("Expected 3 rows of full help bindings, got %d", len(bindings)) 607 + } 608 + }) 609 + }) 610 + 611 + t.Run("Integration", func(t *testing.T) { 612 + t.Run("creates and displays publication view", func(t *testing.T) { 613 + note := createMockNote() 614 + var buf bytes.Buffer 615 + 616 + pv := NewPublicationView(note, PublicationViewOptions{ 617 + Output: &buf, 618 + Static: true, 619 + Width: 80, 620 + Height: 24, 621 + }) 622 + 623 + if pv == nil { 624 + t.Fatal("NewPublicationView returned nil") 625 + } 626 + 627 + err := pv.Show(context.Background()) 628 + if err != nil { 629 + t.Fatalf("Show failed: %v", err) 630 + } 631 + 632 + output := buf.String() 633 + if len(output) == 0 { 634 + t.Error("No output generated") 635 + } 636 + 637 + if !strings.Contains(output, note.Title) { 638 + t.Error("Note title not displayed") 639 + } 640 + if !strings.Contains(output, "This is the content") { 641 + t.Error("Note content not displayed") 642 + } 643 + }) 644 + 645 + t.Run("creates publication view for draft", func(t *testing.T) { 646 + draft := createDraftNote() 647 + var buf bytes.Buffer 648 + 649 + pv := NewPublicationView(draft, PublicationViewOptions{ 650 + Output: &buf, 651 + Static: true, 652 + }) 653 + 654 + err := pv.Show(context.Background()) 655 + if err != nil { 656 + t.Fatalf("Show failed: %v", err) 657 + } 658 + 659 + output := buf.String() 660 + if !strings.Contains(output, "draft") { 661 + t.Error("Draft status not displayed") 662 + } 663 + }) 664 + }) 665 + }