diff --git a/cmd/publication_commands.go b/cmd/publication_commands.go index 0fec89a..ebfd320 100644 --- a/cmd/publication_commands.go +++ b/cmd/publication_commands.go @@ -149,6 +149,33 @@ Use filters to show specific subsets: listCmd.Flags().BoolP("interactive", "i", false, "Open interactive TUI browser") root.AddCommand(listCmd) + readCmd := &cobra.Command{ + Use: "read [identifier]", + Short: "Read a publication", + Long: `Display a publication's content with formatted markdown rendering. + +The identifier can be: +- Omitted: Display the newest publication +- Database ID: Display publication by note ID (e.g., 42) +- AT Protocol rkey: Display publication by leaflet rkey + +Examples: + noteleaf pub read # Show newest publication + noteleaf pub read 123 # Show publication with note ID 123 + noteleaf pub read 3jxx... # Show publication by rkey`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + identifier := "" + if len(args) > 0 { + identifier = args[0] + } + + defer c.handler.Close() + return c.handler.Read(cmd.Context(), identifier) + }, + } + root.AddCommand(readCmd) + statusCmd := &cobra.Command{ Use: "status", Short: "Show leaflet authentication status", diff --git a/cmd/publication_commands_test.go b/cmd/publication_commands_test.go index 025d918..1555ef1 100644 --- a/cmd/publication_commands_test.go +++ b/cmd/publication_commands_test.go @@ -5,7 +5,9 @@ import ( "strings" "testing" + "github.com/spf13/cobra" "github.com/stormlightlabs/noteleaf/internal/handlers" + "github.com/stormlightlabs/noteleaf/internal/shared" ) func createTestPublicationHandler(t *testing.T) (*handlers.PublicationHandler, func()) { @@ -65,6 +67,7 @@ func TestPublicationCommand(t *testing.T) { "auth [handle]", "pull", "list [--published|--draft|--all] [--interactive]", + "read [identifier]", "status", "post [note-id]", "patch [note-id]", @@ -161,6 +164,71 @@ func TestPublicationCommand(t *testing.T) { }) }) + t.Run("Read Command", func(t *testing.T) { + t.Run("reads without identifier", func(t *testing.T) { + handler, cleanup := createTestPublicationHandler(t) + defer cleanup() + + cmd := NewPublicationCommand(handler).Create() + cmd.SetArgs([]string{"read"}) + err := cmd.Execute() + + if err == nil { + t.Error("Expected read to fail when no publications exist") + } + + shared.AssertErrorContains(t, err, "note not found", "") + }) + + t.Run("fails with non-existent note ID", func(t *testing.T) { + handler, cleanup := createTestPublicationHandler(t) + defer cleanup() + + cmd := NewPublicationCommand(handler).Create() + cmd.SetArgs([]string{"read", "999"}) + err := cmd.Execute() + + shared.AssertError(t, err, "read to fail with non-existent ID") + }) + + t.Run("fails with non-existent rkey", func(t *testing.T) { + handler, cleanup := createTestPublicationHandler(t) + defer cleanup() + + cmd := NewPublicationCommand(handler).Create() + cmd.SetArgs([]string{"read", "3jxxxxxxxxxxxx"}) + err := cmd.Execute() + + if err == nil { + t.Error("Expected read to fail with non-existent rkey") + } + }) + + t.Run("accepts optional identifier argument", func(t *testing.T) { + handler, cleanup := createTestPublicationHandler(t) + defer cleanup() + + cmd := NewPublicationCommand(handler).Create() + subcommands := cmd.Commands() + + var readCmd *cobra.Command + for _, subcmd := range subcommands { + if strings.HasPrefix(subcmd.Use, "read") { + readCmd = subcmd + break + } + } + + if readCmd == nil { + t.Fatal("read command not found") + } + + if readCmd.Use != "read [identifier]" { + t.Errorf("Expected Use to be 'read [identifier]', got '%s'", readCmd.Use) + } + }) + }) + t.Run("Pull Command", func(t *testing.T) { t.Run("fails when not authenticated", func(t *testing.T) { handler, cleanup := createTestPublicationHandler(t) diff --git a/go.mod b/go.mod index 9396fb5..ba15db6 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -73,6 +74,7 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect + github.com/x448/float16 v0.8.4 // indirect gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect diff --git a/go.sum b/go.sum index 2c09392..b8e8f42 100644 --- a/go.sum +++ b/go.sum @@ -85,6 +85,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -361,6 +363,8 @@ github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11 h1:5HZfQkwe0mIf github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11/go.mod h1:Wlo/SzPmxVp6vXpGt/zaXhHH0fn4IxgqZc82aKg6bpQ= github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/internal/docs/ROADMAP.md b/internal/docs/ROADMAP.md index 2b1c4bc..379c3e8 100644 --- a/internal/docs/ROADMAP.md +++ b/internal/docs/ROADMAP.md @@ -40,6 +40,17 @@ The foundation across all domains is implemented. Tasks support CRUD operations, - [x] Confirm filtering by tags and `--archived`. - [x] Ensure notes can be opened, edited in `$EDITOR`, and deleted. +#### Publication + +- [x] Implement authentication with BlueSky/leaflet (AT Protocol). + - [ ] Add OAuth2 +- [x] Verify `pub pull` fetches and syncs documents from leaflet. +- [x] Confirm `pub list` with status filtering (`all`, `published`, `draft`). +- [ ] Test `pub post` creates new documents with draft/preview/validate modes. +- [ ] Ensure `pub patch` updates existing documents correctly. +- [ ] Validate `pub push` handles batch operations (create/update). +- [ ] Verify markdown conversion to leaflet block format (headings, code, images, facets). + ### Media Domains #### Books @@ -92,9 +103,9 @@ The foundation across all domains is implemented. Tasks support CRUD operations, ### QA -- [ ] Verify **unit tests** for all handlers (TaskHandler, NoteHandler, Media Handlers). -- [ ] Write **integration tests** covering CLI flows. -- [ ] Ensure error handling works for: +- [x] Verify **unit tests** for all handlers (TaskHandler, NoteHandler, Media Handlers). +- [x] Write **integration tests** covering CLI flows. +- [x] Ensure error handling works for: - Invalid IDs - Invalid flags - Schema corruption (already tested in repo) @@ -356,6 +367,9 @@ SQLite persistence, CI with GitHub Actions and Codecov, TUIs with Charm stack, i - Installation and usage documentation - Contribution guide and developer docs - Consistent argument parsing + +#### Post v1 + - Backup/restore - Multiple profiles - Optional synchronization @@ -367,16 +381,16 @@ SQLite persistence, CI with GitHub Actions and Codecov, TUIs with Charm stack, i | Tasks | CRUD | Complete | | Tasks | Projects/tags | Complete | | Tasks | Time tracking | Complete | -| Tasks | Dependencies | Planned | -| Tasks | Recurrence | Planned | +| Tasks | Dependencies | Complete | +| Tasks | Recurrence | Complete | | Tasks | Wait/scheduled | Planned | | Tasks | Urgency scoring | Planned | | Notes | CRUD | Complete | | Notes | Search/tagging | Planned | | Media | Books/movies/TV | Complete | -| Media | Articles | Planned | +| Media | Articles | Complete | | Media | Source/ratings | Planned | -| Articles | Parser + storage | Planned | +| Articles | Parser + storage | Complete | | System | SQLite persistence | Complete | | System | Synchronization | Future | | System | Import/export formats | Future | diff --git a/internal/handlers/publication.go b/internal/handlers/publication.go index c31a7e0..fd187a7 100644 --- a/internal/handlers/publication.go +++ b/internal/handlers/publication.go @@ -126,6 +126,11 @@ func (h *PublicationHandler) Pull(ctx context.Context) error { return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") } + if configDir, err := store.GetConfigDir(); err == nil { + logFile := filepath.Join(configDir, "logs", fmt.Sprintf("publication_%s.log", time.Now().Format("2006-01-02"))) + ui.Infoln("Detailed logs: %s", logFile) + } + ui.Infoln("Fetching documents from leaflet...") docs, err := h.atproto.PullDocuments(ctx) @@ -140,70 +145,52 @@ func (h *PublicationHandler) Pull(ctx context.Context) error { ui.Infoln("Found %d document(s). Syncing...\n", len(docs)) - var created, updated int - - for _, doc := range docs { - existing, err := h.repos.Notes.GetByLeafletRKey(ctx, doc.Meta.RKey) - if err == nil && existing != nil { - content, err := documentToMarkdown(doc) - if err != nil { - ui.Warningln("Skipping document %s: %v", doc.Document.Title, err) - continue - } - - existing.Title = doc.Document.Title - existing.Content = content - existing.LeafletCID = &doc.Meta.CID - existing.IsDraft = doc.Meta.IsDraft - - if doc.Document.PublishedAt != "" { - publishedAt, err := time.Parse(time.RFC3339, doc.Document.PublishedAt) - if err == nil { - existing.PublishedAt = &publishedAt - } - } - - if err := h.repos.Notes.Update(ctx, existing); err != nil { - ui.Warningln("Failed to update note for document %s: %v", doc.Document.Title, err) - continue - } + ui.Infoln("Removing existing publications...") + if err := h.repos.Notes.DeleteAllLeafletNotes(ctx); err != nil { + return fmt.Errorf("failed to delete existing publications: %w", err) + } - updated++ - ui.Infoln(" Updated: %s", doc.Document.Title) - } else { - content, err := documentToMarkdown(doc) - if err != nil { - ui.Warningln("Skipping document %s: %v", doc.Document.Title, err) - continue - } + var created, failed int - note := &models.Note{ - Title: doc.Document.Title, - Content: content, - LeafletRKey: &doc.Meta.RKey, - LeafletCID: &doc.Meta.CID, - IsDraft: doc.Meta.IsDraft, - } + for _, doc := range docs { + content, err := documentToMarkdown(doc) + if err != nil { + ui.Warningln("Skipping document %s: %v", doc.Document.Title, err) + failed++ + continue + } - if doc.Document.PublishedAt != "" { - publishedAt, err := time.Parse(time.RFC3339, doc.Document.PublishedAt) - if err == nil { - note.PublishedAt = &publishedAt - } - } + note := &models.Note{ + Title: doc.Document.Title, + Content: content, + LeafletRKey: &doc.Meta.RKey, + LeafletCID: &doc.Meta.CID, + IsDraft: doc.Meta.IsDraft, + } - _, err = h.repos.Notes.Create(ctx, note) - if err != nil { - ui.Warningln("Failed to create note for document %s: %v", doc.Document.Title, err) - continue + if doc.Document.PublishedAt != "" { + publishedAt, err := time.Parse(time.RFC3339, doc.Document.PublishedAt) + if err == nil { + note.PublishedAt = &publishedAt } + } - created++ - ui.Infoln(" Created: %s", doc.Document.Title) + _, err = h.repos.Notes.Create(ctx, note) + if err != nil { + ui.Warningln("Failed to create note for document %s: %v", doc.Document.Title, err) + failed++ + continue } + + created++ + ui.Infoln(" Created: %s", doc.Document.Title) } - ui.Successln("Sync complete: %d created, %d updated", created, updated) + if failed > 0 { + ui.Successln("Sync complete: %d created, %d failed", created, failed) + } else { + ui.Successln("Sync complete: %d created", created) + } return nil } @@ -464,6 +451,43 @@ func (h *PublicationHandler) Browse(ctx context.Context, filter string) error { return list.Browse(ctx) } +// Read displays a publication's content with formatted markdown rendering. +// The identifier can be: +// - empty string: display the newest publication +// - numeric string: treat as database ID +// - non-numeric string: treat as AT Protocol rkey +func (h *PublicationHandler) Read(ctx context.Context, identifier string) error { + var note *models.Note + var err error + + if identifier == "" { + note, err = h.repos.Notes.GetNewestPublication(ctx) + if err != nil { + return fmt.Errorf("failed to get newest publication: %w", err) + } + } else { + var id int64 + _, scanErr := fmt.Sscanf(identifier, "%d", &id) + if scanErr == nil { + note, err = h.repos.Notes.Get(ctx, id) + if err != nil { + return fmt.Errorf("failed to get publication by ID: %w", err) + } + if !note.HasLeafletAssociation() { + return fmt.Errorf("note %d is not a publication", id) + } + } else { + note, err = h.repos.Notes.GetByLeafletRKey(ctx, identifier) + if err != nil { + return fmt.Errorf("failed to get publication by rkey: %w", err) + } + } + } + + view := ui.NewPublicationView(note, ui.PublicationViewOptions{}) + return view.Show(ctx) +} + // prepareDocumentForPublish prepares a note for publication by converting to Leaflet format func (h *PublicationHandler) prepareDocumentForPublish(ctx context.Context, noteID int64, isDraft bool, forPatch bool) (*models.Note, *public.Document, error) { note, err := h.repos.Notes.Get(ctx, noteID) diff --git a/internal/handlers/publication_test.go b/internal/handlers/publication_test.go index b266cb0..21b8659 100644 --- a/internal/handlers/publication_test.go +++ b/internal/handlers/publication_test.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "fmt" "strings" "testing" "time" @@ -1968,6 +1969,84 @@ func TestPublicationHandler(t *testing.T) { }) }) + t.Run("Read", func(t *testing.T) { + t.Run("returns error when no publications exist", func(t *testing.T) { + suite := NewHandlerTestSuite(t) + defer suite.Cleanup() + + handler := CreateHandler(t, NewPublicationHandler) + ctx := context.Background() + + err := handler.Read(ctx, "") + if err == nil { + t.Error("Expected error when no publications exist") + } + + if !strings.Contains(err.Error(), "note not found") { + t.Errorf("Expected 'note not found' error, got '%v'", err) + } + }) + + t.Run("returns error for non-existent numeric ID", func(t *testing.T) { + suite := NewHandlerTestSuite(t) + defer suite.Cleanup() + + handler := CreateHandler(t, NewPublicationHandler) + ctx := context.Background() + + err := handler.Read(ctx, "999") + if err == nil { + t.Error("Expected error for non-existent ID") + } + + if !strings.Contains(err.Error(), "failed to get publication by ID") { + t.Errorf("Expected 'failed to get publication by ID' error, got '%v'", err) + } + }) + + t.Run("returns error when note by ID is not a publication", func(t *testing.T) { + suite := NewHandlerTestSuite(t) + defer suite.Cleanup() + + handler := CreateHandler(t, NewPublicationHandler) + ctx := context.Background() + + regularNote := &models.Note{ + Title: "Regular Note", + Content: "# Not a publication", + } + + id, err := handler.repos.Notes.Create(ctx, regularNote) + suite.AssertNoError(err, "create regular note") + + err = handler.Read(ctx, fmt.Sprintf("%d", id)) + if err == nil { + t.Error("Expected error when note is not a publication") + } + + if !strings.Contains(err.Error(), "not a publication") { + t.Errorf("Expected 'not a publication' error, got '%v'", err) + } + }) + + t.Run("returns error for non-existent rkey", func(t *testing.T) { + suite := NewHandlerTestSuite(t) + defer suite.Cleanup() + + handler := CreateHandler(t, NewPublicationHandler) + ctx := context.Background() + + err := handler.Read(ctx, "nonexistent_rkey") + if err == nil { + t.Error("Expected error for non-existent rkey") + } + + if !strings.Contains(err.Error(), "failed to get publication by rkey") { + t.Errorf("Expected 'failed to get publication by rkey' error, got '%v'", err) + } + }) + }) + t.Run("Auth Success Path", func(t *testing.T) { suite := NewHandlerTestSuite(t) defer suite.Cleanup() diff --git a/internal/public/public.go b/internal/public/public.go index b9a9e90..267208a 100644 --- a/internal/public/public.go +++ b/internal/public/public.go @@ -7,12 +7,15 @@ // https://github.com/hyperlink-academy/leaflet/tree/main/lexicons/pub/leaflet/ package public -import "time" +import ( + "encoding/json" + "time" +) const ( - TypeDocument = "pub.leaflet.document" - TypeDocumentDraft = "pub.leaflet.document.draft" - TypePublication = "pub.leaflet.publication" + TypeDocument = "pub.leaflet.document" + TypeDocumentDraft = "pub.leaflet.document.draft" + TypePublication = "pub.leaflet.publication" TypeLinearDocument = "pub.leaflet.pages.linearDocument" TypeBlock = "pub.leaflet.pages.linearDocument#block" @@ -64,6 +67,87 @@ type BlockWrap struct { Alignment string `json:"alignment,omitempty"` // #textAlignLeft, etc. } +type TypeCheck struct { + Type string `json:"$type"` +} + +// UnmarshalJSON custom unmarshaler for BlockWrap to properly type the Block field +// +// Matches against field $type to deserialize data +func (bw *BlockWrap) UnmarshalJSON(data []byte) error { + type Alias BlockWrap + temp := &struct { + Block json.RawMessage `json:"block"` + *Alias + }{ + Alias: (*Alias)(bw), + } + + if err := json.Unmarshal(data, temp); err != nil { + return err + } + + var typeCheck struct { + Type string `json:"$type"` + } + if err := json.Unmarshal(temp.Block, &typeCheck); err != nil { + return err + } + + switch typeCheck.Type { + case TypeTextBlock: + var block TextBlock + if err := json.Unmarshal(temp.Block, &block); err != nil { + return err + } + bw.Block = block + case TypeHeaderBlock: + var block HeaderBlock + if err := json.Unmarshal(temp.Block, &block); err != nil { + return err + } + bw.Block = block + case TypeCodeBlock: + var block CodeBlock + if err := json.Unmarshal(temp.Block, &block); err != nil { + return err + } + bw.Block = block + case TypeImageBlock: + var block ImageBlock + if err := json.Unmarshal(temp.Block, &block); err != nil { + return err + } + bw.Block = block + case TypeBlockquoteBlock: + var block BlockquoteBlock + if err := json.Unmarshal(temp.Block, &block); err != nil { + return err + } + bw.Block = block + case TypeUnorderedListBlock: + var block UnorderedListBlock + if err := json.Unmarshal(temp.Block, &block); err != nil { + return err + } + bw.Block = block + case TypeHorizontalRuleBlock: + var block HorizontalRuleBlock + if err := json.Unmarshal(temp.Block, &block); err != nil { + return err + } + bw.Block = block + default: + var block map[string]any + if err := json.Unmarshal(temp.Block, &block); err != nil { + return err + } + bw.Block = block + } + + return nil +} + // TextBlock represents a text content block (pub.leaflet.blocks.text) type TextBlock struct { Type string `json:"$type"` @@ -122,6 +206,58 @@ type ListItem struct { Children []ListItem `json:"children,omitempty"` // Nested list items } +// UnmarshalJSON custom unmarshaler for ListItem to properly type the Content field +func (li *ListItem) UnmarshalJSON(data []byte) error { + type Alias ListItem + temp := &struct { + Content json.RawMessage `json:"content"` + *Alias + }{ + Alias: (*Alias)(li), + } + + if err := json.Unmarshal(data, temp); err != nil { + return err + } + + var typeCheck struct { + Type string `json:"$type"` + } + if err := json.Unmarshal(temp.Content, &typeCheck); err != nil { + return err + } + + switch typeCheck.Type { + case TypeTextBlock: + var block TextBlock + if err := json.Unmarshal(temp.Content, &block); err != nil { + return err + } + li.Content = block + case TypeHeaderBlock: + var block HeaderBlock + if err := json.Unmarshal(temp.Content, &block); err != nil { + return err + } + li.Content = block + case TypeImageBlock: + var block ImageBlock + if err := json.Unmarshal(temp.Content, &block); err != nil { + return err + } + li.Content = block + default: + // For unknown types, leave as map + var block map[string]any + if err := json.Unmarshal(temp.Content, &block); err != nil { + return err + } + li.Content = block + } + + return nil +} + // HorizontalRuleBlock represents a horizontal rule/thematic break (pub.leaflet.blocks.horizontalRule) type HorizontalRuleBlock struct { Type string `json:"$type"` @@ -134,6 +270,82 @@ type Facet struct { Features []FacetFeature `json:"features"` } +// UnmarshalJSON custom unmarshaler for Facet to properly type the Features field +func (f *Facet) UnmarshalJSON(data []byte) error { + type Alias Facet + temp := &struct { + Features []json.RawMessage `json:"features"` + *Alias + }{ + Alias: (*Alias)(f), + } + + if err := json.Unmarshal(data, temp); err != nil { + return err + } + + f.Features = make([]FacetFeature, 0, len(temp.Features)) + for _, featureData := range temp.Features { + var typeCheck TypeCheck + if err := json.Unmarshal(featureData, &typeCheck); err != nil { + return err + } + + var feature FacetFeature + switch typeCheck.Type { + case TypeFacetBold: + var fb FacetBold + if err := json.Unmarshal(featureData, &fb); err != nil { + return err + } + feature = fb + case TypeFacetItalic: + var fi FacetItalic + if err := json.Unmarshal(featureData, &fi); err != nil { + return err + } + feature = fi + case TypeFacetCode: + var fc FacetCode + if err := json.Unmarshal(featureData, &fc); err != nil { + return err + } + feature = fc + case TypeFacetLink: + var fl FacetLink + if err := json.Unmarshal(featureData, &fl); err != nil { + return err + } + feature = fl + case TypeFacetStrike: + var fs FacetStrikethrough + if err := json.Unmarshal(featureData, &fs); err != nil { + return err + } + feature = fs + case TypeFacetUnderline: + var fu FacetUnderline + if err := json.Unmarshal(featureData, &fu); err != nil { + return err + } + feature = fu + case TypeFacetHighlight: + var fh FacetHighlight + if err := json.Unmarshal(featureData, &fh); err != nil { + return err + } + feature = fh + default: + // Skip unknown feature types + continue + } + + f.Features = append(f.Features, feature) + } + + return nil +} + // ByteSlice specifies a substring range using UTF-8 byte offsets (pub.leaflet.richtext.facet#byteSlice) type ByteSlice struct { Type string `json:"$type"` diff --git a/internal/public/public_test.go b/internal/public/public_test.go new file mode 100644 index 0000000..e95c2df --- /dev/null +++ b/internal/public/public_test.go @@ -0,0 +1,899 @@ +package public + +import ( + "encoding/json" + "testing" + + "github.com/stormlightlabs/noteleaf/internal/shared" +) + +func TestBlockWrap(t *testing.T) { + t.Run("UnmarshalJSON", func(t *testing.T) { + t.Run("unmarshals text block", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.pages.linearDocument#block", + "block": { + "$type": "pub.leaflet.blocks.text", + "plaintext": "Hello world" + } + }` + + var bw BlockWrap + err := json.Unmarshal([]byte(jsonData), &bw) + shared.AssertNoError(t, err, "unmarshal should succeed") + shared.AssertEqual(t, TypeBlock, bw.Type, "type should match") + + block, ok := bw.Block.(TextBlock) + shared.AssertTrue(t, ok, "block should be TextBlock") + shared.AssertEqual(t, TypeTextBlock, block.Type, "block type should match") + shared.AssertEqual(t, "Hello world", block.Plaintext, "plaintext should match") + }) + + t.Run("unmarshals header block", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.pages.linearDocument#block", + "block": { + "$type": "pub.leaflet.blocks.header", + "level": 2, + "plaintext": "Section Title" + } + }` + + var bw BlockWrap + err := json.Unmarshal([]byte(jsonData), &bw) + shared.AssertNoError(t, err, "unmarshal should succeed") + + block, ok := bw.Block.(HeaderBlock) + shared.AssertTrue(t, ok, "block should be HeaderBlock") + shared.AssertEqual(t, TypeHeaderBlock, block.Type, "block type should match") + shared.AssertEqual(t, 2, block.Level, "level should match") + shared.AssertEqual(t, "Section Title", block.Plaintext, "plaintext should match") + }) + + t.Run("unmarshals code block", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.pages.linearDocument#block", + "block": { + "$type": "pub.leaflet.blocks.code", + "plaintext": "fmt.Println(\"test\")", + "language": "go" + } + }` + + var bw BlockWrap + err := json.Unmarshal([]byte(jsonData), &bw) + shared.AssertNoError(t, err, "unmarshal should succeed") + + block, ok := bw.Block.(CodeBlock) + shared.AssertTrue(t, ok, "block should be CodeBlock") + shared.AssertEqual(t, TypeCodeBlock, block.Type, "block type should match") + shared.AssertEqual(t, "go", block.Language, "language should match") + shared.AssertEqual(t, "fmt.Println(\"test\")", block.Plaintext, "plaintext should match") + }) + + t.Run("unmarshals image block", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.pages.linearDocument#block", + "block": { + "$type": "pub.leaflet.blocks.image", + "image": { + "$type": "blob", + "ref": { + "$link": "bafytest123" + }, + "mimeType": "image/png", + "size": 1024 + }, + "alt": "Test image", + "aspectRatio": { + "$type": "pub.leaflet.blocks.image#aspectRatio", + "width": 800, + "height": 600 + } + } + }` + + var bw BlockWrap + err := json.Unmarshal([]byte(jsonData), &bw) + shared.AssertNoError(t, err, "unmarshal should succeed") + + block, ok := bw.Block.(ImageBlock) + shared.AssertTrue(t, ok, "block should be ImageBlock") + shared.AssertEqual(t, TypeImageBlock, block.Type, "block type should match") + shared.AssertEqual(t, "Test image", block.Alt, "alt text should match") + shared.AssertEqual(t, 800, block.AspectRatio.Width, "width should match") + shared.AssertEqual(t, 600, block.AspectRatio.Height, "height should match") + shared.AssertEqual(t, "image/png", block.Image.MimeType, "mime type should match") + shared.AssertEqual(t, 1024, block.Image.Size, "size should match") + }) + + t.Run("unmarshals blockquote block", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.pages.linearDocument#block", + "block": { + "$type": "pub.leaflet.blocks.blockquote", + "plaintext": "This is a quote" + } + }` + + var bw BlockWrap + err := json.Unmarshal([]byte(jsonData), &bw) + shared.AssertNoError(t, err, "unmarshal should succeed") + + block, ok := bw.Block.(BlockquoteBlock) + shared.AssertTrue(t, ok, "block should be BlockquoteBlock") + shared.AssertEqual(t, TypeBlockquoteBlock, block.Type, "block type should match") + shared.AssertEqual(t, "This is a quote", block.Plaintext, "plaintext should match") + }) + + t.Run("unmarshals unordered list block", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.pages.linearDocument#block", + "block": { + "$type": "pub.leaflet.blocks.unorderedList", + "children": [ + { + "$type": "pub.leaflet.blocks.unorderedList#listItem", + "content": { + "$type": "pub.leaflet.blocks.text", + "plaintext": "First item" + } + } + ] + } + }` + + var bw BlockWrap + err := json.Unmarshal([]byte(jsonData), &bw) + shared.AssertNoError(t, err, "unmarshal should succeed") + + block, ok := bw.Block.(UnorderedListBlock) + shared.AssertTrue(t, ok, "block should be UnorderedListBlock") + shared.AssertEqual(t, TypeUnorderedListBlock, block.Type, "block type should match") + shared.AssertEqual(t, 1, len(block.Children), "should have 1 child") + }) + + t.Run("unmarshals horizontal rule block", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.pages.linearDocument#block", + "block": { + "$type": "pub.leaflet.blocks.horizontalRule" + } + }` + + var bw BlockWrap + err := json.Unmarshal([]byte(jsonData), &bw) + shared.AssertNoError(t, err, "unmarshal should succeed") + + block, ok := bw.Block.(HorizontalRuleBlock) + shared.AssertTrue(t, ok, "block should be HorizontalRuleBlock") + shared.AssertEqual(t, TypeHorizontalRuleBlock, block.Type, "block type should match") + }) + + t.Run("unmarshals unknown block type as map", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.pages.linearDocument#block", + "block": { + "$type": "pub.leaflet.blocks.unknown", + "customField": "value" + } + }` + + var bw BlockWrap + err := json.Unmarshal([]byte(jsonData), &bw) + shared.AssertNoError(t, err, "unmarshal should succeed") + + block, ok := bw.Block.(map[string]any) + shared.AssertTrue(t, ok, "block should be map for unknown type") + shared.AssertEqual(t, "pub.leaflet.blocks.unknown", block["$type"], "type should be preserved") + }) + + t.Run("handles block with alignment", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.pages.linearDocument#block", + "block": { + "$type": "pub.leaflet.blocks.text", + "plaintext": "Centered text" + }, + "alignment": "#textAlignCenter" + }` + + var bw BlockWrap + err := json.Unmarshal([]byte(jsonData), &bw) + shared.AssertNoError(t, err, "unmarshal should succeed") + shared.AssertEqual(t, "#textAlignCenter", bw.Alignment, "alignment should match") + }) + + t.Run("returns error for invalid JSON", func(t *testing.T) { + jsonData := `{invalid json` + + var bw BlockWrap + err := json.Unmarshal([]byte(jsonData), &bw) + shared.AssertError(t, err, "invalid JSON should return error") + }) + + t.Run("returns error for malformed block", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.pages.linearDocument#block", + "block": "not an object" + }` + + var bw BlockWrap + err := json.Unmarshal([]byte(jsonData), &bw) + shared.AssertError(t, err, "malformed block should return error") + }) + }) +} + +func TestListItem(t *testing.T) { + t.Run("UnmarshalJSON", func(t *testing.T) { + t.Run("unmarshals text block content", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.blocks.unorderedList#listItem", + "content": { + "$type": "pub.leaflet.blocks.text", + "plaintext": "List item text" + } + }` + + var li ListItem + err := json.Unmarshal([]byte(jsonData), &li) + shared.AssertNoError(t, err, "unmarshal should succeed") + shared.AssertEqual(t, TypeListItem, li.Type, "type should match") + + content, ok := li.Content.(TextBlock) + shared.AssertTrue(t, ok, "content should be TextBlock") + shared.AssertEqual(t, "List item text", content.Plaintext, "plaintext should match") + }) + + t.Run("unmarshals header block content", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.blocks.unorderedList#listItem", + "content": { + "$type": "pub.leaflet.blocks.header", + "level": 3, + "plaintext": "List header" + } + }` + + var li ListItem + err := json.Unmarshal([]byte(jsonData), &li) + shared.AssertNoError(t, err, "unmarshal should succeed") + + content, ok := li.Content.(HeaderBlock) + shared.AssertTrue(t, ok, "content should be HeaderBlock") + shared.AssertEqual(t, 3, content.Level, "level should match") + }) + + t.Run("unmarshals image block content", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.blocks.unorderedList#listItem", + "content": { + "$type": "pub.leaflet.blocks.image", + "image": { + "$type": "blob", + "ref": {"$link": "cid123"}, + "mimeType": "image/jpeg", + "size": 2048 + }, + "alt": "List image", + "aspectRatio": { + "$type": "pub.leaflet.blocks.image#aspectRatio", + "width": 400, + "height": 300 + } + } + }` + + var li ListItem + err := json.Unmarshal([]byte(jsonData), &li) + shared.AssertNoError(t, err, "unmarshal should succeed") + + content, ok := li.Content.(ImageBlock) + shared.AssertTrue(t, ok, "content should be ImageBlock") + shared.AssertEqual(t, "List image", content.Alt, "alt should match") + }) + + t.Run("unmarshals nested list items", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.blocks.unorderedList#listItem", + "content": { + "$type": "pub.leaflet.blocks.text", + "plaintext": "Parent item" + }, + "children": [ + { + "$type": "pub.leaflet.blocks.unorderedList#listItem", + "content": { + "$type": "pub.leaflet.blocks.text", + "plaintext": "Child item" + } + } + ] + }` + + var li ListItem + err := json.Unmarshal([]byte(jsonData), &li) + shared.AssertNoError(t, err, "unmarshal should succeed") + shared.AssertEqual(t, 1, len(li.Children), "should have 1 child") + + childContent, ok := li.Children[0].Content.(TextBlock) + shared.AssertTrue(t, ok, "child content should be TextBlock") + shared.AssertEqual(t, "Child item", childContent.Plaintext, "child plaintext should match") + }) + + t.Run("unmarshals unknown content type as map", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.blocks.unorderedList#listItem", + "content": { + "$type": "pub.leaflet.blocks.custom", + "data": "value" + } + }` + + var li ListItem + err := json.Unmarshal([]byte(jsonData), &li) + shared.AssertNoError(t, err, "unmarshal should succeed") + + content, ok := li.Content.(map[string]any) + shared.AssertTrue(t, ok, "unknown content should be map") + shared.AssertEqual(t, "pub.leaflet.blocks.custom", content["$type"], "type should be preserved") + }) + + t.Run("returns error for invalid JSON", func(t *testing.T) { + jsonData := `{invalid` + + var li ListItem + err := json.Unmarshal([]byte(jsonData), &li) + shared.AssertError(t, err, "invalid JSON should return error") + }) + }) +} + +func TestFacet(t *testing.T) { + t.Run("UnmarshalJSON", func(t *testing.T) { + t.Run("unmarshals bold facet", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.richtext.facet", + "index": { + "$type": "pub.leaflet.richtext.facet#byteSlice", + "byteStart": 0, + "byteEnd": 5 + }, + "features": [ + { + "$type": "pub.leaflet.richtext.facet#bold" + } + ] + }` + + var f Facet + err := json.Unmarshal([]byte(jsonData), &f) + shared.AssertNoError(t, err, "unmarshal should succeed") + shared.AssertEqual(t, TypeFacet, f.Type, "type should match") + shared.AssertEqual(t, 0, f.Index.ByteStart, "byte start should match") + shared.AssertEqual(t, 5, f.Index.ByteEnd, "byte end should match") + shared.AssertEqual(t, 1, len(f.Features), "should have 1 feature") + + bold, ok := f.Features[0].(FacetBold) + shared.AssertTrue(t, ok, "feature should be FacetBold") + shared.AssertEqual(t, TypeFacetBold, bold.GetFacetType(), "facet type should match") + }) + + t.Run("unmarshals italic facet", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.richtext.facet", + "index": { + "$type": "pub.leaflet.richtext.facet#byteSlice", + "byteStart": 5, + "byteEnd": 10 + }, + "features": [ + { + "$type": "pub.leaflet.richtext.facet#italic" + } + ] + }` + + var f Facet + err := json.Unmarshal([]byte(jsonData), &f) + shared.AssertNoError(t, err, "unmarshal should succeed") + + italic, ok := f.Features[0].(FacetItalic) + shared.AssertTrue(t, ok, "feature should be FacetItalic") + shared.AssertEqual(t, TypeFacetItalic, italic.GetFacetType(), "facet type should match") + }) + + t.Run("unmarshals code facet", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.richtext.facet", + "index": { + "$type": "pub.leaflet.richtext.facet#byteSlice", + "byteStart": 0, + "byteEnd": 10 + }, + "features": [ + { + "$type": "pub.leaflet.richtext.facet#code" + } + ] + }` + + var f Facet + err := json.Unmarshal([]byte(jsonData), &f) + shared.AssertNoError(t, err, "unmarshal should succeed") + + code, ok := f.Features[0].(FacetCode) + shared.AssertTrue(t, ok, "feature should be FacetCode") + shared.AssertEqual(t, TypeFacetCode, code.GetFacetType(), "facet type should match") + }) + + t.Run("unmarshals link facet", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.richtext.facet", + "index": { + "$type": "pub.leaflet.richtext.facet#byteSlice", + "byteStart": 0, + "byteEnd": 15 + }, + "features": [ + { + "$type": "pub.leaflet.richtext.facet#link", + "uri": "https://example.com" + } + ] + }` + + var f Facet + err := json.Unmarshal([]byte(jsonData), &f) + shared.AssertNoError(t, err, "unmarshal should succeed") + + link, ok := f.Features[0].(FacetLink) + shared.AssertTrue(t, ok, "feature should be FacetLink") + shared.AssertEqual(t, TypeFacetLink, link.GetFacetType(), "facet type should match") + shared.AssertEqual(t, "https://example.com", link.URI, "URI should match") + }) + + t.Run("unmarshals strikethrough facet", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.richtext.facet", + "index": { + "$type": "pub.leaflet.richtext.facet#byteSlice", + "byteStart": 0, + "byteEnd": 8 + }, + "features": [ + { + "$type": "pub.leaflet.richtext.facet#strikethrough" + } + ] + }` + + var f Facet + err := json.Unmarshal([]byte(jsonData), &f) + shared.AssertNoError(t, err, "unmarshal should succeed") + + strike, ok := f.Features[0].(FacetStrikethrough) + shared.AssertTrue(t, ok, "feature should be FacetStrikethrough") + shared.AssertEqual(t, TypeFacetStrike, strike.GetFacetType(), "facet type should match") + }) + + t.Run("unmarshals underline facet", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.richtext.facet", + "index": { + "$type": "pub.leaflet.richtext.facet#byteSlice", + "byteStart": 0, + "byteEnd": 12 + }, + "features": [ + { + "$type": "pub.leaflet.richtext.facet#underline" + } + ] + }` + + var f Facet + err := json.Unmarshal([]byte(jsonData), &f) + shared.AssertNoError(t, err, "unmarshal should succeed") + + underline, ok := f.Features[0].(FacetUnderline) + shared.AssertTrue(t, ok, "feature should be FacetUnderline") + shared.AssertEqual(t, TypeFacetUnderline, underline.GetFacetType(), "facet type should match") + }) + + t.Run("unmarshals highlight facet", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.richtext.facet", + "index": { + "$type": "pub.leaflet.richtext.facet#byteSlice", + "byteStart": 5, + "byteEnd": 15 + }, + "features": [ + { + "$type": "pub.leaflet.richtext.facet#highlight" + } + ] + }` + + var f Facet + err := json.Unmarshal([]byte(jsonData), &f) + shared.AssertNoError(t, err, "unmarshal should succeed") + + highlight, ok := f.Features[0].(FacetHighlight) + shared.AssertTrue(t, ok, "feature should be FacetHighlight") + shared.AssertEqual(t, TypeFacetHighlight, highlight.GetFacetType(), "facet type should match") + }) + + t.Run("unmarshals multiple features", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.richtext.facet", + "index": { + "$type": "pub.leaflet.richtext.facet#byteSlice", + "byteStart": 0, + "byteEnd": 10 + }, + "features": [ + { + "$type": "pub.leaflet.richtext.facet#bold" + }, + { + "$type": "pub.leaflet.richtext.facet#italic" + }, + { + "$type": "pub.leaflet.richtext.facet#link", + "uri": "https://test.com" + } + ] + }` + + var f Facet + err := json.Unmarshal([]byte(jsonData), &f) + shared.AssertNoError(t, err, "unmarshal should succeed") + shared.AssertEqual(t, 3, len(f.Features), "should have 3 features") + + _, isBold := f.Features[0].(FacetBold) + shared.AssertTrue(t, isBold, "first feature should be bold") + + _, isItalic := f.Features[1].(FacetItalic) + shared.AssertTrue(t, isItalic, "second feature should be italic") + + link, isLink := f.Features[2].(FacetLink) + shared.AssertTrue(t, isLink, "third feature should be link") + shared.AssertEqual(t, "https://test.com", link.URI, "link URI should match") + }) + + t.Run("skips unknown feature types", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.richtext.facet", + "index": { + "$type": "pub.leaflet.richtext.facet#byteSlice", + "byteStart": 0, + "byteEnd": 10 + }, + "features": [ + { + "$type": "pub.leaflet.richtext.facet#bold" + }, + { + "$type": "pub.leaflet.richtext.facet#unknown" + }, + { + "$type": "pub.leaflet.richtext.facet#italic" + } + ] + }` + + var f Facet + err := json.Unmarshal([]byte(jsonData), &f) + shared.AssertNoError(t, err, "unmarshal should succeed") + shared.AssertEqual(t, 2, len(f.Features), "unknown features should be skipped") + }) + + t.Run("returns error for invalid JSON", func(t *testing.T) { + jsonData := `{invalid` + + var f Facet + err := json.Unmarshal([]byte(jsonData), &f) + shared.AssertError(t, err, "invalid JSON should return error") + }) + + t.Run("returns error for malformed feature", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.richtext.facet", + "index": { + "$type": "pub.leaflet.richtext.facet#byteSlice", + "byteStart": 0, + "byteEnd": 10 + }, + "features": [ + "not an object" + ] + }` + + var f Facet + err := json.Unmarshal([]byte(jsonData), &f) + shared.AssertError(t, err, "malformed feature should return error") + }) + }) +} + +func TestDocument(t *testing.T) { + t.Run("marshals and unmarshals correctly", func(t *testing.T) { + doc := Document{ + Type: TypeDocument, + Author: "did:plc:test123", + Title: "Test Document", + Description: "A test document", + PublishedAt: "2024-01-01T00:00:00Z", + Publication: "at://did:plc:test123/pub.leaflet.publication/rkey", + Pages: []LinearDocument{ + { + Type: TypeLinearDocument, + ID: "page1", + Blocks: []BlockWrap{ + { + Type: TypeBlock, + Block: TextBlock{ + Type: TypeTextBlock, + Plaintext: "Test content", + }, + }, + }, + }, + }, + } + + data, err := json.Marshal(doc) + shared.AssertNoError(t, err, "marshal should succeed") + + var decoded Document + err = json.Unmarshal(data, &decoded) + shared.AssertNoError(t, err, "unmarshal should succeed") + shared.AssertEqual(t, doc.Title, decoded.Title, "title should match") + shared.AssertEqual(t, doc.Author, decoded.Author, "author should match") + shared.AssertEqual(t, 1, len(decoded.Pages), "should have 1 page") + }) +} + +func TestLinearDocument(t *testing.T) { + t.Run("marshals with multiple block types", func(t *testing.T) { + ld := LinearDocument{ + Type: TypeLinearDocument, + ID: "page1", + Blocks: []BlockWrap{ + { + Type: TypeBlock, + Block: TextBlock{ + Type: TypeTextBlock, + Plaintext: "Text", + }, + }, + { + Type: TypeBlock, + Block: HeaderBlock{ + Type: TypeHeaderBlock, + Level: 1, + Plaintext: "Header", + }, + }, + }, + } + + data, err := json.Marshal(ld) + shared.AssertNoError(t, err, "marshal should succeed") + + var decoded LinearDocument + err = json.Unmarshal(data, &decoded) + shared.AssertNoError(t, err, "unmarshal should succeed") + shared.AssertEqual(t, 2, len(decoded.Blocks), "should have 2 blocks") + }) +} + +func TestFacetFeatures(t *testing.T) { + t.Run("GetFacetType", func(t *testing.T) { + t.Run("returns correct type for bold", func(t *testing.T) { + bold := FacetBold{Type: TypeFacetBold} + shared.AssertEqual(t, TypeFacetBold, bold.GetFacetType(), "type should match") + }) + + t.Run("returns correct type for italic", func(t *testing.T) { + italic := FacetItalic{Type: TypeFacetItalic} + shared.AssertEqual(t, TypeFacetItalic, italic.GetFacetType(), "type should match") + }) + + t.Run("returns correct type for code", func(t *testing.T) { + code := FacetCode{Type: TypeFacetCode} + shared.AssertEqual(t, TypeFacetCode, code.GetFacetType(), "type should match") + }) + + t.Run("returns correct type for link", func(t *testing.T) { + link := FacetLink{Type: TypeFacetLink, URI: "https://example.com"} + shared.AssertEqual(t, TypeFacetLink, link.GetFacetType(), "type should match") + }) + + t.Run("returns correct type for strikethrough", func(t *testing.T) { + strike := FacetStrikethrough{Type: TypeFacetStrike} + shared.AssertEqual(t, TypeFacetStrike, strike.GetFacetType(), "type should match") + }) + + t.Run("returns correct type for underline", func(t *testing.T) { + underline := FacetUnderline{Type: TypeFacetUnderline} + shared.AssertEqual(t, TypeFacetUnderline, underline.GetFacetType(), "type should match") + }) + + t.Run("returns correct type for highlight", func(t *testing.T) { + highlight := FacetHighlight{Type: TypeFacetHighlight} + shared.AssertEqual(t, TypeFacetHighlight, highlight.GetFacetType(), "type should match") + }) + }) +} + +func TestBlob(t *testing.T) { + t.Run("marshals and unmarshals correctly", func(t *testing.T) { + blob := Blob{ + Type: TypeBlob, + Ref: CID{ + Link: "bafytest123", + }, + MimeType: "image/png", + Size: 4096, + } + + data, err := json.Marshal(blob) + shared.AssertNoError(t, err, "marshal should succeed") + + var decoded Blob + err = json.Unmarshal(data, &decoded) + shared.AssertNoError(t, err, "unmarshal should succeed") + shared.AssertEqual(t, blob.MimeType, decoded.MimeType, "mime type should match") + shared.AssertEqual(t, blob.Size, decoded.Size, "size should match") + shared.AssertEqual(t, blob.Ref.Link, decoded.Ref.Link, "CID link should match") + }) +} + +func TestPublication(t *testing.T) { + t.Run("marshals and unmarshals correctly", func(t *testing.T) { + pub := Publication{ + Type: TypePublication, + Name: "Test Publication", + Description: "A test publication", + } + + data, err := json.Marshal(pub) + shared.AssertNoError(t, err, "marshal should succeed") + + var decoded Publication + err = json.Unmarshal(data, &decoded) + shared.AssertNoError(t, err, "unmarshal should succeed") + shared.AssertEqual(t, pub.Name, decoded.Name, "name should match") + shared.AssertEqual(t, pub.Description, decoded.Description, "description should match") + }) +} + +func TestComplexDocument(t *testing.T) { + t.Run("unmarshals complex nested document", func(t *testing.T) { + jsonData := `{ + "$type": "pub.leaflet.document", + "author": "did:plc:abc123", + "title": "Complex Document", + "description": "Testing complex structures", + "publishedAt": "2024-01-15T10:30:00Z", + "publication": "at://did:plc:abc123/pub.leaflet.publication/xyz", + "pages": [ + { + "$type": "pub.leaflet.pages.linearDocument", + "id": "page1", + "blocks": [ + { + "$type": "pub.leaflet.pages.linearDocument#block", + "block": { + "$type": "pub.leaflet.blocks.header", + "level": 1, + "plaintext": "Introduction", + "facets": [ + { + "$type": "pub.leaflet.richtext.facet", + "index": { + "$type": "pub.leaflet.richtext.facet#byteSlice", + "byteStart": 0, + "byteEnd": 12 + }, + "features": [ + { + "$type": "pub.leaflet.richtext.facet#bold" + } + ] + } + ] + } + }, + { + "$type": "pub.leaflet.pages.linearDocument#block", + "block": { + "$type": "pub.leaflet.blocks.text", + "plaintext": "This is a link to example", + "facets": [ + { + "$type": "pub.leaflet.richtext.facet", + "index": { + "$type": "pub.leaflet.richtext.facet#byteSlice", + "byteStart": 10, + "byteEnd": 14 + }, + "features": [ + { + "$type": "pub.leaflet.richtext.facet#link", + "uri": "https://example.com" + } + ] + } + ] + } + }, + { + "$type": "pub.leaflet.pages.linearDocument#block", + "block": { + "$type": "pub.leaflet.blocks.unorderedList", + "children": [ + { + "$type": "pub.leaflet.blocks.unorderedList#listItem", + "content": { + "$type": "pub.leaflet.blocks.text", + "plaintext": "First item" + }, + "children": [ + { + "$type": "pub.leaflet.blocks.unorderedList#listItem", + "content": { + "$type": "pub.leaflet.blocks.text", + "plaintext": "Nested item" + } + } + ] + } + ] + } + }, + { + "$type": "pub.leaflet.pages.linearDocument#block", + "block": { + "$type": "pub.leaflet.blocks.horizontalRule" + } + } + ] + } + ] + }` + + var doc Document + err := json.Unmarshal([]byte(jsonData), &doc) + shared.AssertNoError(t, err, "unmarshal should succeed") + shared.AssertEqual(t, TypeDocument, doc.Type, "type should match") + shared.AssertEqual(t, "Complex Document", doc.Title, "title should match") + shared.AssertEqual(t, 1, len(doc.Pages), "should have 1 page") + shared.AssertEqual(t, 4, len(doc.Pages[0].Blocks), "should have 4 blocks") + + headerBlock, ok := doc.Pages[0].Blocks[0].Block.(HeaderBlock) + shared.AssertTrue(t, ok, "first block should be HeaderBlock") + shared.AssertEqual(t, 1, headerBlock.Level, "header level should be 1") + shared.AssertEqual(t, 1, len(headerBlock.Facets), "header should have 1 facet") + + textBlock, ok := doc.Pages[0].Blocks[1].Block.(TextBlock) + shared.AssertTrue(t, ok, "second block should be TextBlock") + shared.AssertEqual(t, 1, len(textBlock.Facets), "text should have 1 facet") + link, ok := textBlock.Facets[0].Features[0].(FacetLink) + shared.AssertTrue(t, ok, "facet feature should be link") + shared.AssertEqual(t, "https://example.com", link.URI, "link URI should match") + + listBlock, ok := doc.Pages[0].Blocks[2].Block.(UnorderedListBlock) + shared.AssertTrue(t, ok, "third block should be UnorderedListBlock") + shared.AssertEqual(t, 1, len(listBlock.Children), "list should have 1 child") + shared.AssertEqual(t, 1, len(listBlock.Children[0].Children), "first item should have 1 nested child") + + hrBlock, ok := doc.Pages[0].Blocks[3].Block.(HorizontalRuleBlock) + shared.AssertTrue(t, ok, "fourth block should be HorizontalRuleBlock") + shared.AssertEqual(t, TypeHorizontalRuleBlock, hrBlock.Type, "HR type should match") + }) +} diff --git a/internal/repo/note_repository.go b/internal/repo/note_repository.go index dd0b495..48bd09b 100644 --- a/internal/repo/note_repository.go +++ b/internal/repo/note_repository.go @@ -333,3 +333,18 @@ func (r *NoteRepository) GetLeafletNotes(ctx context.Context) ([]*models.Note, e query := "SELECT " + noteColumns + " FROM notes WHERE leaflet_rkey IS NOT NULL ORDER BY modified DESC" return r.queryMany(ctx, query) } + +// GetNewestPublication returns the most recently published leaflet note +func (r *NoteRepository) GetNewestPublication(ctx context.Context) (*models.Note, error) { + query := "SELECT " + noteColumns + " FROM notes WHERE leaflet_rkey IS NOT NULL ORDER BY published_at DESC LIMIT 1" + return r.queryOne(ctx, query) +} + +// DeleteAllLeafletNotes removes all notes with leaflet associations +func (r *NoteRepository) DeleteAllLeafletNotes(ctx context.Context) error { + _, err := r.db.ExecContext(ctx, "DELETE FROM notes WHERE leaflet_rkey IS NOT NULL") + if err != nil { + return fmt.Errorf("failed to delete leaflet notes: %w", err) + } + return nil +} diff --git a/internal/repo/note_repository_test.go b/internal/repo/note_repository_test.go index 275ae63..b692d3c 100644 --- a/internal/repo/note_repository_test.go +++ b/internal/repo/note_repository_test.go @@ -607,6 +607,46 @@ func TestNoteRepository(t *testing.T) { } }) + t.Run("GetNewestPublication returns most recent published note", func(t *testing.T) { + note, err := repo.GetNewestPublication(ctx) + shared.AssertNoError(t, err, "get newest publication") + shared.AssertNotNil(t, note, "should return a note") + shared.AssertFalse(t, note.IsDraft, "newest should not be draft") + shared.AssertNotNil(t, note.PublishedAt, "should have published_at") + }) + + t.Run("GetNewestPublication returns error when no published notes", func(t *testing.T) { + emptyDB := CreateTestDB(t) + emptyRepo := NewNoteRepository(emptyDB) + + _, err := emptyRepo.GetNewestPublication(ctx) + shared.AssertError(t, err, "should error when no published notes exist") + }) + + t.Run("DeleteAllLeafletNotes removes all leaflet notes", func(t *testing.T) { + beforeLeaflet, err := repo.GetLeafletNotes(ctx) + shared.AssertNoError(t, err, "get leaflet notes before delete") + shared.AssertEqual(t, 3, len(beforeLeaflet), "should have 3 leaflet notes before delete") + + allNotes, err := repo.List(ctx, NoteListOptions{}) + shared.AssertNoError(t, err, "get all notes before delete") + shared.AssertEqual(t, 4, len(allNotes), "should have 4 total notes before delete") + + err = repo.DeleteAllLeafletNotes(ctx) + shared.AssertNoError(t, err, "delete all leaflet notes") + + afterLeaflet, err := repo.GetLeafletNotes(ctx) + shared.AssertNoError(t, err, "get leaflet notes after delete") + shared.AssertEqual(t, 0, len(afterLeaflet), "should have 0 leaflet notes after delete") + + remainingNotes, err := repo.List(ctx, NoteListOptions{}) + shared.AssertNoError(t, err, "get remaining notes") + shared.AssertEqual(t, 1, len(remainingNotes), "should have 1 regular note remaining") + if remainingNotes[0].LeafletRKey != nil { + t.Error("remaining note should not have leaflet rkey") + } + }) + t.Run("Context Cancellation", func(t *testing.T) { t.Run("GetByLeafletRKey with cancelled context", func(t *testing.T) { _, err := repo.GetByLeafletRKey(NewCanceledContext(), rkey1) @@ -627,6 +667,16 @@ func TestNoteRepository(t *testing.T) { _, err := repo.GetLeafletNotes(NewCanceledContext()) AssertCancelledContext(t, err) }) + + t.Run("GetNewestPublication with cancelled context", func(t *testing.T) { + _, err := repo.GetNewestPublication(NewCanceledContext()) + AssertCancelledContext(t, err) + }) + + t.Run("DeleteAllLeafletNotes with cancelled context", func(t *testing.T) { + err := repo.DeleteAllLeafletNotes(NewCanceledContext()) + AssertCancelledContext(t, err) + }) }) }) } diff --git a/internal/services/atproto.go b/internal/services/atproto.go index 200d1f6..334f272 100644 --- a/internal/services/atproto.go +++ b/internal/services/atproto.go @@ -19,6 +19,7 @@ import ( lexutil "github.com/bluesky-social/indigo/lex/util" "github.com/bluesky-social/indigo/repo" "github.com/bluesky-social/indigo/xrpc" + "github.com/fxamacker/cbor/v2" "github.com/ipfs/go-cid" "github.com/stormlightlabs/noteleaf/internal/public" ) @@ -29,6 +30,35 @@ type DocumentWithMeta struct { Meta public.DocumentMeta } +// convertCBORToJSONCompatible recursively converts CBOR data structures to JSON-compatible types +// +// This converts map[any]any to map[string]any to allow usage of [json.Marshal] +func convertCBORToJSONCompatible(data any) any { + switch v := data.(type) { + case map[any]any: + result := make(map[string]any, len(v)) + for key, value := range v { + strKey := fmt.Sprintf("%v", key) + result[strKey] = convertCBORToJSONCompatible(value) + } + return result + case map[string]any: + result := make(map[string]any, len(v)) + for key, value := range v { + result[key] = convertCBORToJSONCompatible(value) + } + return result + case []any: + result := make([]any, len(v)) + for i, item := range v { + result[i] = convertCBORToJSONCompatible(item) + } + return result + default: + return v + } +} + // PublicationWithMeta combines a publication with its metadata type PublicationWithMeta struct { Publication public.Publication @@ -212,15 +242,25 @@ func (s *ATProtoService) PullDocuments(ctx context.Context) ([]DocumentWithMeta, var documents []DocumentWithMeta prefix := public.TypeDocument + documentCount := 0 err = r.ForEach(ctx, prefix, func(k string, v cid.Cid) error { + documentCount++ + _, recordBytes, err := r.GetRecordBytes(ctx, k) if err != nil { return fmt.Errorf("failed to get record bytes for %s: %w", k, err) } - var doc public.Document - if err := json.Unmarshal(*recordBytes, &doc); err != nil { - return fmt.Errorf("failed to unmarshal document %s: %w", k, err) + var cborData any + if err := cbor.Unmarshal(*recordBytes, &cborData); err != nil { + return fmt.Errorf("failed to decode CBOR for document %s: %w", k, err) + } + + jsonCompatible := convertCBORToJSONCompatible(cborData) + + jsonBytes, err := json.MarshalIndent(jsonCompatible, "", " ") + if err != nil { + return fmt.Errorf("failed to convert CBOR to JSON for document %s: %w", k, err) } parts := strings.Split(k, "/") @@ -229,6 +269,21 @@ func (s *ATProtoService) PullDocuments(ctx context.Context) ([]DocumentWithMeta, rkey = parts[len(parts)-1] } + var typeCheck public.TypeCheck + + if err := json.Unmarshal(jsonBytes, &typeCheck); err != nil { + return fmt.Errorf("failed to check $type for %s: %w", k, err) + } + + if typeCheck.Type != public.TypeDocument { + return nil + } + + var doc public.Document + if err := json.Unmarshal(jsonBytes, &doc); err != nil { + return fmt.Errorf("failed to unmarshal JSON to Document for %s: %w", k, err) + } + uri := fmt.Sprintf("at://%s/%s", s.session.DID, k) meta := public.DocumentMeta{ @@ -291,21 +346,18 @@ func (s *ATProtoService) ListPublications(ctx context.Context) ([]PublicationWit } uri := fmt.Sprintf("at://%s/%s", s.session.DID, k) - publications = append(publications, PublicationWithMeta{ Publication: pub, RKey: rkey, CID: v.String(), URI: uri, }) - return nil }) if err != nil { return nil, fmt.Errorf("failed to iterate over publications: %w", err) } - return publications, nil } @@ -480,7 +532,6 @@ func (s *ATProtoService) UploadBlob(ctx context.Context, data []byte, mimeType s MimeType: output.Blob.MimeType, Size: int(output.Blob.Size), } - return blob, nil } diff --git a/internal/shared/test_utilities.go b/internal/shared/test_utilities.go index c3fe03e..c1204cb 100644 --- a/internal/shared/test_utilities.go +++ b/internal/shared/test_utilities.go @@ -64,8 +64,13 @@ func AssertErrorContains(t *testing.T, err error, expected, msg string) { t.Errorf("%s: expected error but got none", msg) return } - if expected != "" && !ContainsString(err.Error(), expected) { - t.Errorf("%s: expected error containing %q, got: %v", msg, expected, err) + + if !ContainsString(err.Error(), expected) { + if msg == "" { + t.Errorf("expected error containing %q, got: %v", expected, err) + } else if expected != "" { + t.Errorf("%s: expected error containing %q, got: %v", msg, expected, err) + } } } diff --git a/internal/ui/data_list.go b/internal/ui/data_list.go index da3af7f..9347647 100644 --- a/internal/ui/data_list.go +++ b/internal/ui/data_list.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/stormlightlabs/noteleaf/internal/models" @@ -157,21 +158,22 @@ type ( ) type dataListModel struct { - items []ListItem - selected int - viewing bool - viewContent string - searching bool - searchQuery string - err error - loading bool - source ListSource - opts DataListOptions - keys DataListKeyMap - help help.Model - showingHelp bool - totalCount int - listOpts ListOptions + items []ListItem + selected int + viewing bool + viewContent string + viewViewport viewport.Model + searching bool + searchQuery string + err error + loading bool + source ListSource + opts DataListOptions + keys DataListKeyMap + help help.Model + showingHelp bool + totalCount int + listOpts ListOptions } func (m dataListModel) Init() tea.Cmd { @@ -199,6 +201,18 @@ func (m dataListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Help): m.showingHelp = true return m, nil + case key.Matches(msg, m.keys.Up): + m.viewViewport.ScrollUp(1) + case key.Matches(msg, m.keys.Down): + m.viewViewport.ScrollDown(1) + case msg.String() == "pgup", msg.String() == "b": + m.viewViewport.HalfPageUp() + case msg.String() == "pgdown", msg.String() == "f": + m.viewViewport.HalfPageDown() + case msg.String() == "g", msg.String() == "home": + m.viewViewport.GotoTop() + case msg.String() == "G", msg.String() == "end": + m.viewViewport.GotoBottom() } return m, nil } @@ -279,11 +293,21 @@ func (m dataListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case listViewMsg: m.viewContent = string(msg) m.viewing = true + m.viewViewport = viewport.New(80, 20) + m.viewViewport.SetContent(m.viewContent) case listErrorMsg: m.err = error(msg) m.loading = false case listCountMsg: m.totalCount = int(msg) + case tea.WindowSizeMsg: + if m.viewing { + headerHeight := 2 + footerHeight := 3 + verticalMarginHeight := headerHeight + footerHeight + m.viewViewport.Width = msg.Width + m.viewViewport.Height = msg.Height - verticalMarginHeight + } } return m, nil } @@ -298,9 +322,9 @@ func (m dataListModel) View() string { } if m.viewing { - s.WriteString(m.viewContent) + s.WriteString(m.viewViewport.View()) s.WriteString("\n\n") - s.WriteString(style.Render("Press q/esc/backspace to return to list, ? for help")) + s.WriteString(style.Render("↑/↓/pgup/pgdn: scroll | g/G: top/bottom | q/esc: back | ?: help")) return s.String() } diff --git a/internal/ui/data_list_test.go b/internal/ui/data_list_test.go index d9b31b2..8ee392d 100644 --- a/internal/ui/data_list_test.go +++ b/internal/ui/data_list_test.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" ) @@ -732,17 +733,21 @@ func TestDataList(t *testing.T) { }) t.Run("viewing mode", func(t *testing.T) { + vp := viewport.New(80, 20) + vp.SetContent("# Test Content\nDetails here") + model := dataListModel{ - viewing: true, - viewContent: "# Test Content\nDetails here", - opts: DataListOptions{}, + viewing: true, + viewContent: "# Test Content\nDetails here", + viewViewport: vp, + opts: DataListOptions{}, } view := model.View() if !strings.Contains(view, "# Test Content") { t.Error("View content not displayed") } - if !strings.Contains(view, "Press q/esc/backspace to return") { + if !strings.Contains(view, "q/esc: back") { t.Error("Return instructions not displayed") } }) diff --git a/internal/ui/publication_view.go b/internal/ui/publication_view.go new file mode 100644 index 0000000..280cc8a --- /dev/null +++ b/internal/ui/publication_view.go @@ -0,0 +1,279 @@ +package ui + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" + "github.com/stormlightlabs/noteleaf/internal/models" +) + +// PublicationViewOptions configures the publication view UI behavior +type PublicationViewOptions struct { + Output io.Writer + Input io.Reader + Static bool + Width int + Height int +} + +// PublicationView handles publication viewing UI with pager +type PublicationView struct { + note *models.Note + opts PublicationViewOptions +} + +// NewPublicationView creates a new publication view UI component +func NewPublicationView(note *models.Note, opts PublicationViewOptions) *PublicationView { + if opts.Output == nil { + opts.Output = os.Stdout + } + if opts.Input == nil { + opts.Input = os.Stdin + } + if opts.Width == 0 { + opts.Width = 80 + } + if opts.Height == 0 { + opts.Height = 24 + } + return &PublicationView{note: note, opts: opts} +} + +// Publication view specific key bindings +type publicationViewKeyMap struct { + Up key.Binding + Down key.Binding + PageUp key.Binding + PageDown key.Binding + Top key.Binding + Bottom key.Binding + Quit key.Binding + Back key.Binding + Help key.Binding +} + +func (k publicationViewKeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Up, k.Down, k.Back, k.Help, k.Quit} +} + +func (k publicationViewKeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Up, k.Down, k.PageUp, k.PageDown}, + {k.Top, k.Bottom}, + {k.Help, k.Back, k.Quit}, + } +} + +var publicationViewKeys = publicationViewKeyMap{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "scroll up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "scroll down")), + PageUp: key.NewBinding(key.WithKeys("pgup", "b"), key.WithHelp("pgup/b", "page up")), + PageDown: key.NewBinding(key.WithKeys("pgdown", "f"), key.WithHelp("pgdown/f", "page down")), + Top: key.NewBinding(key.WithKeys("home", "g"), key.WithHelp("home/g", "go to top")), + Bottom: key.NewBinding(key.WithKeys("end", "G"), key.WithHelp("end/G", "go to bottom")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + Back: key.NewBinding(key.WithKeys("esc", "backspace"), key.WithHelp("esc", "back")), + Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), +} + +type publicationViewModel struct { + note *models.Note + viewport viewport.Model + keys publicationViewKeyMap + help help.Model + showingHelp bool + opts PublicationViewOptions + ready bool +} + +func (m publicationViewModel) Init() tea.Cmd { + return nil +} + +func (m publicationViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + if m.showingHelp { + switch { + case key.Matches(msg, m.keys.Back) || key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Help): + m.showingHelp = false + return m, nil + } + return m, nil + } + + switch { + case key.Matches(msg, m.keys.Quit) || key.Matches(msg, m.keys.Back): + return m, tea.Quit + case key.Matches(msg, m.keys.Help): + m.showingHelp = true + return m, nil + case key.Matches(msg, m.keys.Up): + m.viewport.ScrollUp(1) + case key.Matches(msg, m.keys.Down): + m.viewport.ScrollDown(1) + case key.Matches(msg, m.keys.PageUp): + m.viewport.HalfPageUp() + case key.Matches(msg, m.keys.PageDown): + m.viewport.HalfPageDown() + case key.Matches(msg, m.keys.Top): + m.viewport.GotoTop() + case key.Matches(msg, m.keys.Bottom): + m.viewport.GotoBottom() + } + + case tea.WindowSizeMsg: + headerHeight := 3 + footerHeight := 3 + verticalMarginHeight := headerHeight + footerHeight + + if !m.opts.Static { + m.viewport.Width = msg.Width - 2 + m.viewport.Height = msg.Height - verticalMarginHeight + } + + if !m.ready { + m.ready = true + } + } + + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m publicationViewModel) View() string { + if m.showingHelp { + return m.help.View(m.keys) + } + + status := "published" + if m.note.IsDraft { + status = "draft" + } + + title := TitleColorStyle.Render(fmt.Sprintf("%s (%s)", m.note.Title, status)) + content := m.viewport.View() + help := lipgloss.NewStyle().Foreground(lipgloss.Color(Squid.Hex())).Render(m.help.View(m.keys)) + + if !m.ready { + return "\n Initializing..." + } + + return lipgloss.JoinVertical(lipgloss.Left, title, "", content, "", help) +} + +// buildPublicationMarkdown creates the markdown content for rendering +func buildPublicationMarkdown(note *models.Note) string { + var content strings.Builder + + content.WriteString("# " + note.Title + "\n\n") + + status := "published" + if note.IsDraft { + status = "draft" + } + content.WriteString("**Status:** " + status + "\n") + + if note.PublishedAt != nil { + content.WriteString("**Published:** " + note.PublishedAt.Format("2006-01-02 15:04") + "\n") + } + + content.WriteString("**Modified:** " + note.Modified.Format("2006-01-02 15:04") + "\n") + + if note.LeafletRKey != nil { + content.WriteString("**RKey:** `" + *note.LeafletRKey + "`\n") + } + + if note.LeafletCID != nil { + content.WriteString("**CID:** `" + *note.LeafletCID + "`\n") + } + + content.WriteString("\n---\n\n") + + noteContent := strings.TrimSpace(note.Content) + if !strings.HasPrefix(noteContent, "# ") { + content.WriteString(noteContent) + } else { + lines := strings.Split(noteContent, "\n") + if len(lines) > 1 { + content.WriteString(strings.Join(lines[1:], "\n")) + } + } + + return content.String() +} + +// formatPublicationContent renders markdown with glamour for viewport display +func formatPublicationContent(note *models.Note) (string, error) { + markdown := buildPublicationMarkdown(note) + + renderer, err := glamour.NewTermRenderer( + glamour.WithAutoStyle(), + glamour.WithWordWrap(80), + ) + if err != nil { + return markdown, fmt.Errorf("failed to create renderer: %w", err) + } + + rendered, err := renderer.Render(markdown) + if err != nil { + return markdown, fmt.Errorf("failed to render markdown: %w", err) + } + + return rendered, nil +} + +// Show displays the publication in interactive mode with pager +func (pv *PublicationView) Show(ctx context.Context) error { + if pv.opts.Static { + return pv.staticShow(ctx) + } + + content, err := formatPublicationContent(pv.note) + if err != nil { + return err + } + + vp := viewport.New(pv.opts.Width-2, pv.opts.Height-6) + vp.SetContent(content) + + model := publicationViewModel{ + note: pv.note, + viewport: vp, + keys: publicationViewKeys, + help: help.New(), + opts: pv.opts, + } + + program := tea.NewProgram( + model, + tea.WithInput(pv.opts.Input), + tea.WithOutput(pv.opts.Output), + tea.WithAltScreen(), + tea.WithMouseCellMotion(), + ) + + _, err = program.Run() + return err +} + +func (pv *PublicationView) staticShow(context.Context) error { + content, err := formatPublicationContent(pv.note) + if err != nil { + return err + } + + fmt.Fprint(pv.opts.Output, content) + return nil +} diff --git a/internal/ui/publication_view_test.go b/internal/ui/publication_view_test.go new file mode 100644 index 0000000..698eafd --- /dev/null +++ b/internal/ui/publication_view_test.go @@ -0,0 +1,665 @@ +package ui + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/stormlightlabs/noteleaf/internal/models" +) + +func createMockNote() *models.Note { + now := time.Now() + publishedAt := now.Add(-24 * time.Hour) + rkey := "test-rkey-123" + cid := "test-cid-456" + + return &models.Note{ + ID: 1, + Title: "Test Publication", + Content: "# Test Publication\n\nThis is the content of the test publication.", + Tags: []string{"test", "publication"}, + Archived: false, + Created: now.Add(-48 * time.Hour), + Modified: now.Add(-1 * time.Hour), + LeafletRKey: &rkey, + LeafletCID: &cid, + PublishedAt: &publishedAt, + IsDraft: false, + } +} + +func createDraftNote() *models.Note { + note := createMockNote() + note.IsDraft = true + note.PublishedAt = nil + note.LeafletRKey = nil + note.LeafletCID = nil + return note +} + +func createMinimalNote() *models.Note { + now := time.Now() + return &models.Note{ + ID: 2, + Title: "Minimal Note", + Content: "Simple content without markdown heading.", + Created: now, + Modified: now, + IsDraft: true, + } +} + +func TestPublicationView(t *testing.T) { + t.Run("View Options", func(t *testing.T) { + note := createMockNote() + + t.Run("default options", func(t *testing.T) { + opts := PublicationViewOptions{} + pv := NewPublicationView(note, opts) + + if pv.opts.Output == nil { + t.Error("Output should default to os.Stdout") + } + if pv.opts.Input == nil { + t.Error("Input should default to os.Stdin") + } + if pv.opts.Width != 80 { + t.Errorf("Width should default to 80, got %d", pv.opts.Width) + } + if pv.opts.Height != 24 { + t.Errorf("Height should default to 24, got %d", pv.opts.Height) + } + }) + + t.Run("custom options", func(t *testing.T) { + var buf bytes.Buffer + opts := PublicationViewOptions{ + Output: &buf, + Static: true, + Width: 100, + Height: 30, + } + pv := NewPublicationView(note, opts) + + if pv.opts.Output != &buf { + t.Error("Custom output not set") + } + if !pv.opts.Static { + t.Error("Static mode not set") + } + if pv.opts.Width != 100 { + t.Error("Custom width not set") + } + if pv.opts.Height != 30 { + t.Error("Custom height not set") + } + }) + }) + + t.Run("New", func(t *testing.T) { + note := createMockNote() + + t.Run("creates publication view correctly", func(t *testing.T) { + opts := PublicationViewOptions{Width: 60, Height: 20} + pv := NewPublicationView(note, opts) + + if pv.note != note { + t.Error("Note not set correctly") + } + if pv.opts.Width != 60 { + t.Error("Width not set correctly") + } + if pv.opts.Height != 20 { + t.Error("Height not set correctly") + } + }) + }) + + t.Run("Static Mode", func(t *testing.T) { + t.Run("published note display", func(t *testing.T) { + note := createMockNote() + var buf bytes.Buffer + + pv := NewPublicationView(note, PublicationViewOptions{ + Output: &buf, + Static: true, + }) + + err := pv.Show(context.Background()) + if err != nil { + t.Fatalf("Show failed: %v", err) + } + + output := buf.String() + + if !strings.Contains(output, "Test Publication") { + t.Error("Note title not displayed") + } + if !strings.Contains(output, "published") { + t.Error("Published status not displayed") + } + if !strings.Contains(output, "Published:") { + t.Error("Published date not displayed") + } + if !strings.Contains(output, "Modified:") { + t.Error("Modified date not displayed") + } + if !strings.Contains(output, "RKey:") { + t.Error("RKey not displayed") + } + if !strings.Contains(output, "test-rkey-123") { + t.Error("RKey value not displayed") + } + if !strings.Contains(output, "CID:") { + t.Error("CID not displayed") + } + if !strings.Contains(output, "test-cid-456") { + t.Error("CID value not displayed") + } + if !strings.Contains(output, "This is the content") { + t.Error("Note content not displayed") + } + }) + + t.Run("draft note display", func(t *testing.T) { + note := createDraftNote() + var buf bytes.Buffer + + pv := NewPublicationView(note, PublicationViewOptions{ + Output: &buf, + Static: true, + }) + + err := pv.Show(context.Background()) + if err != nil { + t.Fatalf("Show failed: %v", err) + } + + output := buf.String() + + if !strings.Contains(output, "draft") { + t.Error("Draft status not displayed") + } + if strings.Contains(output, "Published:") { + t.Error("Published date should not be displayed for draft") + } + if strings.Contains(output, "RKey:") { + t.Error("RKey should not be displayed for draft") + } + if strings.Contains(output, "CID:") { + t.Error("CID should not be displayed for draft") + } + }) + + t.Run("minimal note display", func(t *testing.T) { + note := createMinimalNote() + var buf bytes.Buffer + + pv := NewPublicationView(note, PublicationViewOptions{ + Output: &buf, + Static: true, + }) + + err := pv.Show(context.Background()) + if err != nil { + t.Fatalf("Show failed: %v", err) + } + + output := buf.String() + + if !strings.Contains(output, "Minimal Note") { + t.Error("Note title not displayed") + } + if !strings.Contains(output, "Simple content") { + t.Error("Note content not displayed") + } + if !strings.Contains(output, "Modified:") { + t.Error("Modified date not displayed") + } + }) + }) + + t.Run("Build Markdown", func(t *testing.T) { + t.Run("builds markdown for published note", func(t *testing.T) { + note := createMockNote() + markdown := buildPublicationMarkdown(note) + + expectedStrings := []string{ + "# Test Publication", + "**Status:** published", + "**Published:**", + "**Modified:**", + "**RKey:** `test-rkey-123`", + "**CID:** `test-cid-456`", + "---", + "This is the content", + } + + for _, expected := range expectedStrings { + if !strings.Contains(markdown, expected) { + t.Errorf("Expected markdown '%s' not found in output", expected) + } + } + }) + + t.Run("builds markdown for draft note", func(t *testing.T) { + note := createDraftNote() + markdown := buildPublicationMarkdown(note) + + if !strings.Contains(markdown, "**Status:** draft") { + t.Error("Draft status not in markdown") + } + if strings.Contains(markdown, "**Published:**") { + t.Error("Published date should not be in draft markdown") + } + if strings.Contains(markdown, "**RKey:**") { + t.Error("RKey should not be in draft markdown") + } + if strings.Contains(markdown, "**CID:**") { + t.Error("CID should not be in draft markdown") + } + }) + + t.Run("handles content with markdown heading", func(t *testing.T) { + note := createMockNote() + markdown := buildPublicationMarkdown(note) + + headingCount := strings.Count(markdown, "# Test Publication") + if headingCount != 1 { + t.Errorf("Expected 1 heading, found %d (content heading should be stripped)", headingCount) + } + }) + + t.Run("handles content without markdown heading", func(t *testing.T) { + note := createMinimalNote() + markdown := buildPublicationMarkdown(note) + + if !strings.Contains(markdown, "Simple content") { + t.Error("Content without heading should be included as-is") + } + }) + }) + + t.Run("Format Content", func(t *testing.T) { + t.Run("formats content with glamour", func(t *testing.T) { + note := createMockNote() + content, err := formatPublicationContent(note) + + if err != nil { + t.Fatalf("formatPublicationContent failed: %v", err) + } + + if len(content) == 0 { + t.Error("Formatted content should not be empty") + } + }) + + t.Run("includes note title in formatted content", func(t *testing.T) { + note := createMockNote() + content, err := formatPublicationContent(note) + + if err != nil { + t.Fatalf("formatPublicationContent failed: %v", err) + } + + if !strings.Contains(content, "Test Publication") { + t.Error("Formatted content should include note title") + } + }) + }) + + t.Run("Model", func(t *testing.T) { + note := createMockNote() + + t.Run("initial model state", func(t *testing.T) { + model := publicationViewModel{ + note: note, + opts: PublicationViewOptions{Width: 80, Height: 24}, + } + + if model.showingHelp { + t.Error("Initial showingHelp should be false") + } + if model.note != note { + t.Error("Note not set correctly") + } + }) + + t.Run("key handling - help toggle", func(t *testing.T) { + vp := viewport.New(80, 20) + model := publicationViewModel{ + note: note, + viewport: vp, + keys: publicationViewKeys, + help: help.New(), + ready: true, + } + + newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")}) + if m, ok := newModel.(publicationViewModel); ok { + if !m.showingHelp { + t.Error("Help key should show help") + } + } + + model.showingHelp = true + newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("?")}) + if m, ok := newModel.(publicationViewModel); ok { + if m.showingHelp { + t.Error("Help key should exit help when already showing") + } + } + }) + + t.Run("key handling - quit and back", func(t *testing.T) { + vp := viewport.New(80, 20) + model := publicationViewModel{ + note: note, + viewport: vp, + keys: publicationViewKeys, + help: help.New(), + ready: true, + } + + quitKeys := []string{"q", "esc"} + for _, key := range quitKeys { + _, cmd := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) + if cmd == nil { + t.Errorf("Key %s should return quit command", key) + } + } + }) + + t.Run("viewport navigation", func(t *testing.T) { + vp := viewport.New(80, 20) + longContent := strings.Repeat("Line of content\n", 50) + vp.SetContent(longContent) + + model := publicationViewModel{ + note: note, + viewport: vp, + keys: publicationViewKeys, + help: help.New(), + ready: true, + } + + initialOffset := model.viewport.YOffset + + newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")}) + if m, ok := newModel.(publicationViewModel); ok { + if m.viewport.YOffset <= initialOffset { + t.Error("Down key should scroll viewport down") + } + } + + model.viewport.ScrollDown(5) + initialOffset = model.viewport.YOffset + newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")}) + if m, ok := newModel.(publicationViewModel); ok { + if m.viewport.YOffset >= initialOffset { + t.Error("Up key should scroll viewport up") + } + } + }) + + t.Run("page navigation", func(t *testing.T) { + vp := viewport.New(80, 20) + longContent := strings.Repeat("Line of content\n", 100) + vp.SetContent(longContent) + + model := publicationViewModel{ + note: note, + viewport: vp, + keys: publicationViewKeys, + help: help.New(), + ready: true, + } + + initialOffset := model.viewport.YOffset + + newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("f")}) + if m, ok := newModel.(publicationViewModel); ok { + if m.viewport.YOffset <= initialOffset { + t.Error("Page down key should scroll viewport down") + } + } + }) + + t.Run("top and bottom navigation", func(t *testing.T) { + vp := viewport.New(80, 20) + longContent := strings.Repeat("Line of content\n", 100) + vp.SetContent(longContent) + + model := publicationViewModel{ + note: note, + viewport: vp, + keys: publicationViewKeys, + help: help.New(), + ready: true, + } + + model.viewport.ScrollDown(50) + + newModel, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("g")}) + if m, ok := newModel.(publicationViewModel); ok { + if m.viewport.YOffset != 0 { + t.Error("Top key should scroll to top") + } + } + + newModel, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("G")}) + if m, ok := newModel.(publicationViewModel); ok { + if m.viewport.YOffset == 0 { + t.Error("Bottom key should scroll to bottom") + } + } + }) + + t.Run("window size message handling", func(t *testing.T) { + vp := viewport.New(80, 20) + model := publicationViewModel{ + note: note, + viewport: vp, + keys: publicationViewKeys, + help: help.New(), + opts: PublicationViewOptions{Static: false}, + } + + newModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + if m, ok := newModel.(publicationViewModel); ok { + if m.viewport.Width != 98 { + t.Errorf("Viewport width should be 98 (100-2), got %d", m.viewport.Width) + } + expectedHeight := 30 - 6 + if m.viewport.Height != expectedHeight { + t.Errorf("Viewport height should be %d, got %d", expectedHeight, m.viewport.Height) + } + if !m.ready { + t.Error("Model should be ready after window size message") + } + } + }) + + t.Run("static mode ignores window resize", func(t *testing.T) { + vp := viewport.New(80, 20) + model := publicationViewModel{ + note: note, + viewport: vp, + keys: publicationViewKeys, + help: help.New(), + opts: PublicationViewOptions{Static: true}, + ready: true, + } + + newModel, _ := model.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) + if m, ok := newModel.(publicationViewModel); ok { + if m.viewport.Width != 80 { + t.Error("Static mode should not resize viewport width") + } + if m.viewport.Height != 20 { + t.Error("Static mode should not resize viewport height") + } + } + }) + }) + + t.Run("View Model", func(t *testing.T) { + note := createMockNote() + + t.Run("normal view with published note", func(t *testing.T) { + vp := viewport.New(80, 20) + content, _ := formatPublicationContent(note) + vp.SetContent(content) + + model := publicationViewModel{ + note: note, + viewport: vp, + keys: publicationViewKeys, + help: help.New(), + ready: true, + } + + view := model.View() + + if !strings.Contains(view, "Test Publication") { + t.Error("Note title not displayed in view") + } + if !strings.Contains(view, "published") { + t.Error("Published status not displayed in view") + } + }) + + t.Run("normal view with draft note", func(t *testing.T) { + draft := createDraftNote() + vp := viewport.New(80, 20) + content, _ := formatPublicationContent(draft) + vp.SetContent(content) + + model := publicationViewModel{ + note: draft, + viewport: vp, + keys: publicationViewKeys, + help: help.New(), + ready: true, + } + + view := model.View() + + if !strings.Contains(view, "draft") { + t.Error("Draft status not displayed in view") + } + }) + + t.Run("help view", func(t *testing.T) { + vp := viewport.New(80, 20) + model := publicationViewModel{ + note: note, + viewport: vp, + keys: publicationViewKeys, + help: help.New(), + showingHelp: true, + ready: true, + } + + view := model.View() + + if !strings.Contains(view, "scroll") { + t.Error("Help view should contain scroll instructions") + } + }) + + t.Run("initializing view", func(t *testing.T) { + vp := viewport.New(80, 20) + model := publicationViewModel{ + note: note, + viewport: vp, + keys: publicationViewKeys, + help: help.New(), + ready: false, + } + + view := model.View() + + if !strings.Contains(view, "Initializing") { + t.Error("Not ready state should show initializing message") + } + }) + }) + + t.Run("Key Bindings", func(t *testing.T) { + t.Run("short help bindings", func(t *testing.T) { + bindings := publicationViewKeys.ShortHelp() + if len(bindings) != 5 { + t.Errorf("Expected 5 short help bindings, got %d", len(bindings)) + } + }) + + t.Run("full help bindings", func(t *testing.T) { + bindings := publicationViewKeys.FullHelp() + if len(bindings) != 3 { + t.Errorf("Expected 3 rows of full help bindings, got %d", len(bindings)) + } + }) + }) + + t.Run("Integration", func(t *testing.T) { + t.Run("creates and displays publication view", func(t *testing.T) { + note := createMockNote() + var buf bytes.Buffer + + pv := NewPublicationView(note, PublicationViewOptions{ + Output: &buf, + Static: true, + Width: 80, + Height: 24, + }) + + if pv == nil { + t.Fatal("NewPublicationView returned nil") + } + + err := pv.Show(context.Background()) + if err != nil { + t.Fatalf("Show failed: %v", err) + } + + output := buf.String() + if len(output) == 0 { + t.Error("No output generated") + } + + if !strings.Contains(output, note.Title) { + t.Error("Note title not displayed") + } + if !strings.Contains(output, "This is the content") { + t.Error("Note content not displayed") + } + }) + + t.Run("creates publication view for draft", func(t *testing.T) { + draft := createDraftNote() + var buf bytes.Buffer + + pv := NewPublicationView(draft, PublicationViewOptions{ + Output: &buf, + Static: true, + }) + + err := pv.Show(context.Background()) + if err != nil { + t.Fatalf("Show failed: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "draft") { + t.Error("Draft status not displayed") + } + }) + }) +}