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

docs: cleanup documentation & add migrations for leaflet fields

* scaffold for leaflet integration

+1606 -36
-13
README.md
··· 5 5 [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) 6 6 [![Go Version](https://img.shields.io/github/go-mod/go-version/stormlightlabs/noteleaf)](go.mod) 7 7 8 - ```sh 9 - ,, ,... 10 - `7MN. `7MF' mm `7MM .d' "" 11 - MMN. M MM MM dM` 12 - M YMb M ,pW"Wq.mmMMmm .gP"Ya MM .gP"Ya ,6"Yb. mMMmm 13 - M `MN. M 6W' `Wb MM ,M' Yb MM ,M' Yb 8) MM MM 14 - M `MM.M 8M M8 MM 8M"""""" MM 8M"""""" ,pm9MM MM 15 - M YMM YA. ,A9 MM YM. , MM YM. , 8M MM MM 16 - .JML. YM `Ybmd9' `Mbmo`Mbmmd'.JMML.`Mbmmd' `Moo9^Yo..JMML. 17 - ``` 18 - 19 8 Noteleaf is a unified personal productivity CLI that combines task management, note-taking, and media tracking in one place. 20 9 It provides TaskWarrior-inspired task management with additional support for notes, articles, books, movies, and TV shows - all built with Golang & Charm.sh libs. Inspired by TaskWarrior & todo.txt CLI applications. 21 10 ··· 71 60 **Status**: Work in Progress (MVP completed) 72 61 73 62 ### Completed 74 - 75 - Core functionality is complete and stable: 76 63 77 64 - Task management with projects and tags 78 65 - Note-taking system
ROADMAP.md internal/docs/ROADMAP.md
+15 -7
cmd/main.go
··· 17 17 ) 18 18 19 19 var ( 20 - newTaskHandler = handlers.NewTaskHandler 21 - newMovieHandler = handlers.NewMovieHandler 22 - newTVHandler = handlers.NewTVHandler 23 - newNoteHandler = handlers.NewNoteHandler 24 - newBookHandler = handlers.NewBookHandler 25 - newArticleHandler = handlers.NewArticleHandler 26 - exc = fang.Execute 20 + newTaskHandler = handlers.NewTaskHandler 21 + newMovieHandler = handlers.NewMovieHandler 22 + newTVHandler = handlers.NewTVHandler 23 + newNoteHandler = handlers.NewNoteHandler 24 + newBookHandler = handlers.NewBookHandler 25 + newArticleHandler = handlers.NewArticleHandler 26 + newPublicationHandler = handlers.NewPublicationHandler 27 + exc = fang.Execute 27 28 ) 28 29 29 30 // App represents the main CLI application ··· 206 207 return 1 207 208 } 208 209 210 + publicationHandler, err := newPublicationHandler() 211 + if err != nil { 212 + log.Error("failed to create publication handler", "err", err) 213 + return 1 214 + } 215 + 209 216 root := rootCmd() 210 217 211 218 coreGroups := []CommandGroup{ 212 219 NewTaskCommand(taskHandler), NewNoteCommand(noteHandler), NewArticleCommand(articleHandler), 220 + NewPublicationCommand(publicationHandler), 213 221 } 214 222 215 223 for _, group := range coreGroups {
+154
cmd/publication_commands.go
··· 1 + // TODO: implement prompt for password 2 + // 3 + // See: https://github.com/charmbracelet/bubbletea/blob/main/examples/textinputs/main.go 4 + package main 5 + 6 + import ( 7 + "fmt" 8 + 9 + "github.com/spf13/cobra" 10 + "github.com/stormlightlabs/noteleaf/internal/handlers" 11 + ) 12 + 13 + // PublicationCommand implements [CommandGroup] for leaflet publication commands 14 + type PublicationCommand struct { 15 + handler *handlers.PublicationHandler 16 + } 17 + 18 + // NewPublicationCommand creates a new [PublicationCommand] with the given handler 19 + func NewPublicationCommand(handler *handlers.PublicationHandler) *PublicationCommand { 20 + return &PublicationCommand{handler: handler} 21 + } 22 + 23 + func (c *PublicationCommand) Create() *cobra.Command { 24 + root := &cobra.Command{ 25 + Use: "pub", 26 + Short: "Manage leaflet publication sync", 27 + Long: `Sync notes with leaflet.pub (AT Protocol publishing platform). 28 + 29 + Authenticate with your BlueSky account to pull drafts and published documents 30 + from leaflet.pub into your local notes. Track publication status and manage 31 + your writing workflow across platforms. 32 + 33 + Authentication uses AT Protocol (the same system as BlueSky). You'll need: 34 + - BlueSky handle (e.g., username.bsky.social) 35 + - App password (generated at bsky.app/settings/app-passwords) 36 + 37 + Getting Started: 38 + 1. Authenticate: noteleaf pub auth <handle> 39 + 2. Pull documents: noteleaf pub pull 40 + 3. List publications: noteleaf pub list`, 41 + } 42 + 43 + authCmd := &cobra.Command{ 44 + Use: "auth [handle]", 45 + Short: "Authenticate with BlueSky/leaflet", 46 + Long: `Authenticate with AT Protocol (BlueSky) for leaflet access. 47 + 48 + Your handle is typically: username.bsky.social 49 + 50 + For the password, use an app password (not your main password): 51 + 1. Go to bsky.app/settings/app-passwords 52 + 2. Create a new app password named "noteleaf" 53 + 3. Use that password here 54 + 55 + The password will be prompted securely if not provided via flag.`, 56 + Args: cobra.MaximumNArgs(1), 57 + RunE: func(cmd *cobra.Command, args []string) error { 58 + var handle string 59 + if len(args) > 0 { 60 + handle = args[0] 61 + } 62 + 63 + password, _ := cmd.Flags().GetString("password") 64 + 65 + if handle == "" { 66 + return fmt.Errorf("handle is required") 67 + } 68 + 69 + if password == "" { 70 + return fmt.Errorf("password is required (use --password flag)") 71 + } 72 + 73 + defer c.handler.Close() 74 + return c.handler.Auth(cmd.Context(), handle, password) 75 + }, 76 + } 77 + authCmd.Flags().StringP("password", "p", "", "App password (will be prompted if not provided)") 78 + root.AddCommand(authCmd) 79 + 80 + pullCmd := &cobra.Command{ 81 + Use: "pull", 82 + Short: "Pull documents from leaflet", 83 + Long: `Fetch all drafts and published documents from leaflet.pub. 84 + 85 + This will: 86 + - Connect to your BlueSky/leaflet account 87 + - Fetch all documents in your repository 88 + - Create new notes for documents not yet synced 89 + - Update existing notes that have changed 90 + 91 + Notes are matched by their leaflet record key (rkey) stored in the database.`, 92 + RunE: func(cmd *cobra.Command, args []string) error { 93 + defer c.handler.Close() 94 + return c.handler.Pull(cmd.Context()) 95 + }, 96 + } 97 + root.AddCommand(pullCmd) 98 + 99 + // List command 100 + listCmd := &cobra.Command{ 101 + Use: "list [--published|--draft|--all]", 102 + Short: "List notes synced with leaflet", 103 + Aliases: []string{"ls"}, 104 + Long: `Display notes that have been pulled from or pushed to leaflet. 105 + 106 + Shows publication metadata including: 107 + - Publication status (draft vs published) 108 + - Published date 109 + - Leaflet record key (rkey) 110 + - Content identifier (cid) for change tracking 111 + 112 + Use filters to show specific subsets: 113 + --published Show only published documents 114 + --draft Show only drafts 115 + --all Show all leaflet documents (default)`, 116 + RunE: func(cmd *cobra.Command, args []string) error { 117 + published, _ := cmd.Flags().GetBool("published") 118 + draft, _ := cmd.Flags().GetBool("draft") 119 + all, _ := cmd.Flags().GetBool("all") 120 + 121 + filter := "all" 122 + if published { 123 + filter = "published" 124 + } else if draft { 125 + filter = "draft" 126 + } else if all { 127 + filter = "all" 128 + } 129 + 130 + defer c.handler.Close() 131 + return c.handler.List(cmd.Context(), filter) 132 + }, 133 + } 134 + listCmd.Flags().Bool("published", false, "Show only published documents") 135 + listCmd.Flags().Bool("draft", false, "Show only drafts") 136 + listCmd.Flags().Bool("all", false, "Show all leaflet documents") 137 + root.AddCommand(listCmd) 138 + 139 + statusCmd := &cobra.Command{ 140 + Use: "status", 141 + Short: "Show leaflet authentication status", 142 + Long: "Display current authentication status and session information.", 143 + RunE: func(cmd *cobra.Command, args []string) error { 144 + defer c.handler.Close() 145 + status := c.handler.GetAuthStatus() 146 + fmt.Println("Leaflet Status:") 147 + fmt.Printf(" %s\n", status) 148 + return nil 149 + }, 150 + } 151 + root.AddCommand(statusCmd) 152 + 153 + return root 154 + }
+125
internal/handlers/publication.go
··· 1 + // TODO: Store credentials securely in [PublicationHandler.Auth] 2 + // Options: 3 + // 1. Use system keyring (go-keyring) 4 + // 2. Store encrypted in config file 5 + // 3. Store in environment variables 6 + // 7 + // TODO: Implement document processing 8 + // For each document: 9 + // 1. Check if note with this leaflet_rkey exists 10 + // 2. If exists: Update note content, title, metadata 11 + // 3. If new: Create new note with leaflet metadata 12 + // 4. Convert document blocks to markdown 13 + // 5. Save to database 14 + // 15 + // TODO: Implement list functionality 16 + // 1. Query notes where leaflet_rkey IS NOT NULL 17 + // 2. Apply filter (published vs draft) - "all", "published", "draft", or empty (default: all) 18 + // 3. Use prior art from package ui and other handlers to render 19 + // 20 + // TODO: Implmenent pull command 21 + // 1. Authenticates with AT Protocol 22 + // 2. Fetches all pub.leaflet.document records 23 + // 3. Creates new notes for documents not seen before 24 + // 4. Updates existing notes (matched by leaflet_rkey) 25 + // 5. Shows summary of pulled documents 26 + package handlers 27 + 28 + import ( 29 + "context" 30 + "fmt" 31 + 32 + "github.com/stormlightlabs/noteleaf/internal/repo" 33 + "github.com/stormlightlabs/noteleaf/internal/services" 34 + "github.com/stormlightlabs/noteleaf/internal/store" 35 + ) 36 + 37 + // PublicationHandler handles leaflet publication commands 38 + type PublicationHandler struct { 39 + db *store.Database 40 + config *store.Config 41 + repos *repo.Repositories 42 + atproto *services.ATProtoService 43 + } 44 + 45 + // NewPublicationHandler creates a new publication handler 46 + func NewPublicationHandler() (*PublicationHandler, error) { 47 + db, err := store.NewDatabase() 48 + if err != nil { 49 + return nil, fmt.Errorf("failed to initialize database: %w", err) 50 + } 51 + 52 + config, err := store.LoadConfig() 53 + if err != nil { 54 + return nil, fmt.Errorf("failed to load configuration: %w", err) 55 + } 56 + 57 + repos := repo.NewRepositories(db.DB) 58 + atproto := services.NewATProtoService() 59 + 60 + return &PublicationHandler{ 61 + db: db, 62 + config: config, 63 + repos: repos, 64 + atproto: atproto, 65 + }, nil 66 + } 67 + 68 + // Close cleans up resources 69 + func (h *PublicationHandler) Close() error { 70 + if h.atproto != nil { 71 + if err := h.atproto.Close(); err != nil { 72 + return err 73 + } 74 + } 75 + if h.db != nil { 76 + return h.db.Close() 77 + } 78 + return nil 79 + } 80 + 81 + // Auth handles authentication with BlueSky/leaflet 82 + func (h *PublicationHandler) Auth(ctx context.Context, handle, password string) error { 83 + if handle == "" { 84 + return fmt.Errorf("handle is required") 85 + } 86 + 87 + if password == "" { 88 + return fmt.Errorf("password is required") 89 + } 90 + 91 + fmt.Printf("Authenticating as %s...\n", handle) 92 + 93 + if err := h.atproto.Authenticate(ctx, handle, password); err != nil { 94 + return fmt.Errorf("authentication failed: %w", err) 95 + } 96 + 97 + fmt.Println("โœ“ Authentication successful!") 98 + fmt.Println("TODO: Implement persistent credential storage") 99 + return nil 100 + } 101 + 102 + // Pull fetches all documents from leaflet and creates/updates local notes 103 + func (h *PublicationHandler) Pull(ctx context.Context) error { 104 + fmt.Println("TODO: Implement document conversion and note creation") 105 + return nil 106 + } 107 + 108 + // List displays notes with leaflet publication metadata, showing all notes that 109 + // have been pulled from or pushed to leaflet 110 + func (h *PublicationHandler) List(ctx context.Context, filter string) error { 111 + fmt.Println("TODO: Implement leaflet document listing") 112 + return nil 113 + } 114 + 115 + // GetAuthStatus returns the current authentication status 116 + func (h *PublicationHandler) GetAuthStatus() string { 117 + if h.atproto.IsAuthenticated() { 118 + session, _ := h.atproto.GetSession() 119 + if session != nil { 120 + return fmt.Sprintf("Authenticated as %s", session.Handle) 121 + } 122 + return "Authenticated (session details unavailable)" 123 + } 124 + return "Not authenticated" 125 + }
+23 -10
internal/models/models.go
··· 165 165 166 166 // Note represents a markdown note 167 167 type Note struct { 168 - ID int64 `json:"id"` 169 - Title string `json:"title"` 170 - Content string `json:"content"` 171 - Tags []string `json:"tags,omitempty"` 172 - Archived bool `json:"archived"` 173 - Created time.Time `json:"created"` 174 - Modified time.Time `json:"modified"` 175 - FilePath string `json:"file_path,omitempty"` 168 + ID int64 `json:"id"` 169 + Title string `json:"title"` 170 + Content string `json:"content"` 171 + Tags []string `json:"tags,omitempty"` 172 + Archived bool `json:"archived"` 173 + Created time.Time `json:"created"` 174 + Modified time.Time `json:"modified"` 175 + FilePath string `json:"file_path,omitempty"` 176 + LeafletRKey *string `json:"leaflet_rkey,omitempty"` // Leaflet record key 177 + LeafletCID *string `json:"leaflet_cid,omitempty"` // Leaflet content identifier 178 + PublishedAt *time.Time `json:"published_at,omitempty"` // Publication timestamp 179 + IsDraft bool `json:"is_draft"` // Draft vs published status 176 180 } 177 181 178 182 // Album represents a music album ··· 261 265 func (t *Task) IsDeleted() bool { return t.Status == "deleted" } 262 266 263 267 // HasPriority returns true if the task has a priority set 264 - func (t *Task) HasPriority() bool { return t.Priority != "" } 265 - 268 + func (t *Task) HasPriority() bool { return t.Priority != "" } 266 269 func (t *Task) IsTodo() bool { return t.Status == StatusTodo } 267 270 func (t *Task) IsInProgress() bool { return t.Status == StatusInProgress } 268 271 func (t *Task) IsBlocked() bool { return t.Status == StatusBlocked } ··· 512 515 // IsArchived returns true if the note is archived 513 516 func (n *Note) IsArchived() bool { 514 517 return n.Archived 518 + } 519 + 520 + // HasLeafletAssociation returns true if the note is associated with a leaflet document 521 + func (n *Note) HasLeafletAssociation() bool { 522 + return n.LeafletRKey != nil 523 + } 524 + 525 + // IsPublished returns true if the note is published on leaflet (not a draft) 526 + func (n *Note) IsPublished() bool { 527 + return n.HasLeafletAssociation() && !n.IsDraft 515 528 } 516 529 517 530 func (n *Note) GetID() int64 { return n.ID }
+89
internal/models/models_test.go
··· 633 633 t.Error("Expected nil tags for empty string") 634 634 } 635 635 }) 636 + 637 + t.Run("Leaflet Association Methods", func(t *testing.T) { 638 + t.Run("has no leaflet association by default", func(t *testing.T) { 639 + note := &Note{} 640 + if note.HasLeafletAssociation() { 641 + t.Error("Note with nil leaflet_rkey should not have association") 642 + } 643 + }) 644 + 645 + t.Run("has leaflet association when rkey is set", func(t *testing.T) { 646 + rkey := "test-rkey-123" 647 + note := &Note{LeafletRKey: &rkey} 648 + 649 + if !note.HasLeafletAssociation() { 650 + t.Error("Note with leaflet_rkey should have association") 651 + } 652 + }) 653 + 654 + t.Run("is not published by default", func(t *testing.T) { 655 + note := &Note{IsDraft: true} 656 + if note.IsPublished() { 657 + t.Error("Draft note should not be published") 658 + } 659 + }) 660 + 661 + t.Run("is published when has association and not draft", func(t *testing.T) { 662 + rkey := "published-rkey" 663 + note := &Note{ 664 + LeafletRKey: &rkey, 665 + IsDraft: false, 666 + } 667 + if !note.IsPublished() { 668 + t.Error("Note with leaflet association and not draft should be published") 669 + } 670 + }) 671 + 672 + t.Run("tracks publication metadata", func(t *testing.T) { 673 + rkey := "test-rkey" 674 + cid := "test-cid" 675 + pubTime := time.Now() 676 + 677 + note := &Note{ 678 + Title: "Test Note", 679 + Content: "Test content", 680 + LeafletRKey: &rkey, 681 + LeafletCID: &cid, 682 + PublishedAt: &pubTime, 683 + IsDraft: false, 684 + } 685 + 686 + if !note.HasLeafletAssociation() { 687 + t.Error("Note should have leaflet association") 688 + } 689 + 690 + if !note.IsPublished() { 691 + t.Error("Note should be published") 692 + } 693 + 694 + if note.LeafletRKey == nil || *note.LeafletRKey != rkey { 695 + t.Errorf("Expected rkey %s, got %v", rkey, note.LeafletRKey) 696 + } 697 + 698 + if note.LeafletCID == nil || *note.LeafletCID != cid { 699 + t.Errorf("Expected cid %s, got %v", cid, note.LeafletCID) 700 + } 701 + 702 + if note.PublishedAt == nil || !note.PublishedAt.Equal(pubTime) { 703 + t.Errorf("Expected published_at %v, got %v", pubTime, note.PublishedAt) 704 + } 705 + }) 706 + 707 + t.Run("handles draft status", func(t *testing.T) { 708 + rkey := "draft-rkey" 709 + note := &Note{ 710 + Title: "Draft Note", 711 + Content: "Draft content", 712 + LeafletRKey: &rkey, 713 + IsDraft: true, 714 + } 715 + 716 + if !note.HasLeafletAssociation() { 717 + t.Error("Draft should still have leaflet association") 718 + } 719 + 720 + if note.IsPublished() { 721 + t.Error("Draft should not be published") 722 + } 723 + }) 724 + }) 636 725 }) 637 726 638 727 t.Run("Album Model", func(t *testing.T) {
+391
internal/public/convert.go
··· 1 + // Package public provides conversion between markdown and leaflet block formats 2 + // 3 + // TODO: Handle overlapping facets 4 + // TODO: Implement image handling - requires blob resolution 5 + package public 6 + 7 + import ( 8 + "bytes" 9 + "fmt" 10 + "strings" 11 + 12 + "github.com/gomarkdown/markdown/ast" 13 + "github.com/gomarkdown/markdown/parser" 14 + ) 15 + 16 + // Converter defines the interface for converting between a document and leaflet formats 17 + type Converter interface { 18 + // ToLeaflet converts content to leaflet blocks 19 + ToLeaflet(content string) ([]BlockWrap, error) 20 + // FromLeaflet converts leaflet blocks back to the original format 21 + FromLeaflet(blocks []BlockWrap) (string, error) 22 + } 23 + 24 + // MarkdownConverter implements the [Converter] interface 25 + type MarkdownConverter struct { 26 + extensions parser.Extensions 27 + } 28 + 29 + type formatContext struct { 30 + features []FacetFeature 31 + start int 32 + } 33 + 34 + // NewMarkdownConverter creates a new markdown converter 35 + func NewMarkdownConverter() *MarkdownConverter { 36 + extensions := parser.CommonExtensions | parser.AutoHeadingIDs 37 + return &MarkdownConverter{ 38 + extensions: extensions, 39 + } 40 + } 41 + 42 + // ToLeaflet converts markdown to leaflet blocks 43 + func (c *MarkdownConverter) ToLeaflet(markdown string) ([]BlockWrap, error) { 44 + p := parser.NewWithExtensions(c.extensions) 45 + doc := p.Parse([]byte(markdown)) 46 + 47 + var blocks []BlockWrap 48 + 49 + for _, child := range doc.GetChildren() { 50 + switch n := child.(type) { 51 + case *ast.Heading: 52 + if block := c.convertHeading(n); block != nil { 53 + blocks = append(blocks, *block) 54 + } 55 + case *ast.Paragraph: 56 + if block := c.convertParagraph(n); block != nil { 57 + blocks = append(blocks, *block) 58 + } 59 + case *ast.CodeBlock: 60 + if block := c.convertCodeBlock(n); block != nil { 61 + blocks = append(blocks, *block) 62 + } 63 + case *ast.BlockQuote: 64 + if block := c.convertBlockquote(n); block != nil { 65 + blocks = append(blocks, *block) 66 + } 67 + case *ast.List: 68 + if block := c.convertList(n); block != nil { 69 + blocks = append(blocks, *block) 70 + } 71 + case *ast.HorizontalRule: 72 + blocks = append(blocks, BlockWrap{ 73 + Type: TypeBlock, 74 + Block: HorizontalRuleBlock{ 75 + Type: TypeHorizontalRuleBlock, 76 + }, 77 + }) 78 + } 79 + } 80 + 81 + return blocks, nil 82 + } 83 + 84 + // convertHeading converts an AST heading to a leaflet HeaderBlock 85 + func (c *MarkdownConverter) convertHeading(node *ast.Heading) *BlockWrap { 86 + text, facets := c.extractTextAndFacets(node) 87 + return &BlockWrap{ 88 + Type: TypeBlock, 89 + Block: HeaderBlock{ 90 + Type: TypeHeaderBlock, 91 + Level: node.Level, 92 + Plaintext: text, 93 + Facets: facets, 94 + }, 95 + } 96 + } 97 + 98 + // convertParagraph converts an AST paragraph to a leaflet TextBlock 99 + func (c *MarkdownConverter) convertParagraph(node *ast.Paragraph) *BlockWrap { 100 + text, facets := c.extractTextAndFacets(node) 101 + if strings.TrimSpace(text) == "" { 102 + return nil 103 + } 104 + 105 + return &BlockWrap{ 106 + Type: TypeBlock, 107 + Block: TextBlock{ 108 + Type: TypeTextBlock, 109 + Plaintext: text, 110 + Facets: facets, 111 + }, 112 + } 113 + } 114 + 115 + // convertCodeBlock converts an AST code block to a leaflet CodeBlock 116 + func (c *MarkdownConverter) convertCodeBlock(node *ast.CodeBlock) *BlockWrap { 117 + return &BlockWrap{ 118 + Type: TypeBlock, 119 + Block: CodeBlock{ 120 + Type: TypeCodeBlock, 121 + Plaintext: string(node.Literal), 122 + Language: string(node.Info), 123 + SyntaxHighlightingTheme: "catppuccin-mocha", 124 + }, 125 + } 126 + } 127 + 128 + // convertBlockquote converts an AST blockquote to a leaflet BlockquoteBlock 129 + func (c *MarkdownConverter) convertBlockquote(node *ast.BlockQuote) *BlockWrap { 130 + text, facets := c.extractTextAndFacets(node) 131 + return &BlockWrap{ 132 + Type: TypeBlock, 133 + Block: BlockquoteBlock{ 134 + Type: TypeBlockquoteBlock, 135 + Plaintext: text, 136 + Facets: facets, 137 + }, 138 + } 139 + } 140 + 141 + // convertList converts an AST list to a leaflet UnorderedListBlock 142 + func (c *MarkdownConverter) convertList(node *ast.List) *BlockWrap { 143 + var items []ListItem 144 + 145 + for _, child := range node.Children { 146 + if listItem, ok := child.(*ast.ListItem); ok { 147 + item := c.convertListItem(listItem) 148 + if item != nil { 149 + items = append(items, *item) 150 + } 151 + } 152 + } 153 + 154 + return &BlockWrap{ 155 + Type: TypeBlock, 156 + Block: UnorderedListBlock{ 157 + Type: TypeUnorderedListBlock, 158 + Children: items, 159 + }, 160 + } 161 + } 162 + 163 + // convertListItem converts an AST list item to a leaflet ListItem 164 + func (c *MarkdownConverter) convertListItem(node *ast.ListItem) *ListItem { 165 + text, facets := c.extractTextAndFacets(node) 166 + return &ListItem{ 167 + Type: TypeListItem, 168 + Content: TextBlock{ 169 + Type: TypeTextBlock, 170 + Plaintext: text, 171 + Facets: facets, 172 + }, 173 + } 174 + } 175 + 176 + // extractTextAndFacets extracts plaintext and facets from an AST node 177 + func (c *MarkdownConverter) extractTextAndFacets(node ast.Node) (string, []Facet) { 178 + var buf bytes.Buffer 179 + var facets []Facet 180 + offset := 0 181 + 182 + var stack []formatContext 183 + 184 + ast.WalkFunc(node, func(n ast.Node, entering bool) ast.WalkStatus { 185 + switch v := n.(type) { 186 + case *ast.Text: 187 + if entering { 188 + content := string(v.Literal) 189 + buf.WriteString(content) 190 + 191 + if len(stack) > 0 { 192 + ctx := stack[len(stack)-1] 193 + facet := Facet{ 194 + Type: TypeFacet, 195 + Index: ByteSlice{ 196 + Type: TypeByteSlice, 197 + ByteStart: offset, 198 + ByteEnd: offset + len(content), 199 + }, 200 + Features: ctx.features, 201 + } 202 + facets = append(facets, facet) 203 + } 204 + 205 + offset += len(content) 206 + } 207 + case *ast.Strong: 208 + if entering { 209 + stack = append(stack, formatContext{ 210 + features: []FacetFeature{FacetBold{Type: TypeFacetBold}}, 211 + start: offset, 212 + }) 213 + } else { 214 + if len(stack) > 0 { 215 + stack = stack[:len(stack)-1] 216 + } 217 + } 218 + case *ast.Emph: 219 + if entering { 220 + stack = append(stack, formatContext{ 221 + features: []FacetFeature{FacetItalic{Type: TypeFacetItalic}}, 222 + start: offset, 223 + }) 224 + } else { 225 + if len(stack) > 0 { 226 + stack = stack[:len(stack)-1] 227 + } 228 + } 229 + case *ast.Del: 230 + if entering { 231 + stack = append(stack, formatContext{ 232 + features: []FacetFeature{FacetStrikethrough{Type: TypeFacetStrike}}, 233 + start: offset, 234 + }) 235 + } else { 236 + if len(stack) > 0 { 237 + stack = stack[:len(stack)-1] 238 + } 239 + } 240 + case *ast.Code: 241 + if entering { 242 + content := string(v.Literal) 243 + buf.WriteString(content) 244 + 245 + facet := Facet{ 246 + Type: TypeFacet, 247 + Index: ByteSlice{ 248 + Type: TypeByteSlice, 249 + ByteStart: offset, 250 + ByteEnd: offset + len(content), 251 + }, 252 + Features: []FacetFeature{FacetCode{Type: TypeFacetCode}}, 253 + } 254 + facets = append(facets, facet) 255 + 256 + offset += len(content) 257 + } 258 + case *ast.Link: 259 + if entering { 260 + stack = append(stack, formatContext{ 261 + features: []FacetFeature{FacetLink{ 262 + Type: TypeFacetLink, 263 + URI: string(v.Destination), 264 + }}, 265 + start: offset, 266 + }) 267 + } else { 268 + if len(stack) > 0 { 269 + stack = stack[:len(stack)-1] 270 + } 271 + } 272 + case *ast.Softbreak, *ast.Hardbreak: 273 + if entering { 274 + buf.WriteString(" ") 275 + offset++ 276 + } 277 + } 278 + return ast.GoToNext 279 + }) 280 + return buf.String(), facets 281 + } 282 + 283 + // FromLeaflet converts leaflet blocks back to markdown 284 + func (c *MarkdownConverter) FromLeaflet(blocks []BlockWrap) (string, error) { 285 + var buf bytes.Buffer 286 + for i, wrap := range blocks { 287 + if i > 0 { 288 + buf.WriteString("\n\n") 289 + } 290 + 291 + switch block := wrap.Block.(type) { 292 + case TextBlock: 293 + buf.WriteString(c.facetsToMarkdown(block.Plaintext, block.Facets)) 294 + case HeaderBlock: 295 + buf.WriteString(strings.Repeat("#", block.Level)) 296 + buf.WriteString(" ") 297 + buf.WriteString(c.facetsToMarkdown(block.Plaintext, block.Facets)) 298 + case CodeBlock: 299 + buf.WriteString("```") 300 + if block.Language != "" { 301 + buf.WriteString(block.Language) 302 + } 303 + buf.WriteString("\n") 304 + buf.WriteString(block.Plaintext) 305 + if !strings.HasSuffix(block.Plaintext, "\n") { 306 + buf.WriteString("\n") 307 + } 308 + buf.WriteString("```") 309 + case BlockquoteBlock: 310 + buf.WriteString("> ") 311 + buf.WriteString(c.facetsToMarkdown(block.Plaintext, block.Facets)) 312 + case UnorderedListBlock: 313 + c.listToMarkdown(&buf, block.Children, 0) 314 + case HorizontalRuleBlock: 315 + buf.WriteString("---") 316 + case ImageBlock: 317 + buf.WriteString("![") 318 + buf.WriteString(block.Alt) 319 + buf.WriteString("](image-placeholder)") 320 + default: 321 + return "", fmt.Errorf("unsupported block type: %T", block) 322 + } 323 + } 324 + 325 + return buf.String(), nil 326 + } 327 + 328 + // facetsToMarkdown applies facets to plaintext and generates markdown 329 + func (c *MarkdownConverter) facetsToMarkdown(text string, facets []Facet) string { 330 + if len(facets) == 0 { 331 + return text 332 + } 333 + 334 + var buf bytes.Buffer 335 + lastEnd := 0 336 + 337 + for _, facet := range facets { 338 + if facet.Index.ByteStart > lastEnd { 339 + buf.WriteString(text[lastEnd:facet.Index.ByteStart]) 340 + } 341 + 342 + facetText := text[facet.Index.ByteStart:facet.Index.ByteEnd] 343 + 344 + for _, feature := range facet.Features { 345 + switch f := feature.(type) { 346 + case FacetBold: 347 + facetText = "**" + facetText + "**" 348 + case FacetItalic: 349 + facetText = "*" + facetText + "*" 350 + case FacetCode: 351 + facetText = "`" + facetText + "`" 352 + case FacetStrikethrough: 353 + facetText = "~~" + facetText + "~~" 354 + case FacetLink: 355 + facetText = "[" + facetText + "](" + f.URI + ")" 356 + } 357 + } 358 + 359 + buf.WriteString(facetText) 360 + lastEnd = facet.Index.ByteEnd 361 + } 362 + 363 + if lastEnd < len(text) { 364 + buf.WriteString(text[lastEnd:]) 365 + } 366 + 367 + return buf.String() 368 + } 369 + 370 + // listToMarkdown converts a list to markdown with proper indentation 371 + func (c *MarkdownConverter) listToMarkdown(buf *bytes.Buffer, items []ListItem, depth int) { 372 + indent := strings.Repeat(" ", depth) 373 + 374 + for _, item := range items { 375 + buf.WriteString(indent) 376 + buf.WriteString("- ") 377 + 378 + switch content := item.Content.(type) { 379 + case TextBlock: 380 + buf.WriteString(c.facetsToMarkdown(content.Plaintext, content.Facets)) 381 + case HeaderBlock: 382 + buf.WriteString(c.facetsToMarkdown(content.Plaintext, content.Facets)) 383 + } 384 + 385 + buf.WriteString("\n") 386 + 387 + if len(item.Children) > 0 { 388 + c.listToMarkdown(buf, item.Children, depth+1) 389 + } 390 + } 391 + }
+282
internal/public/convert_test.go
··· 1 + package public 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + 7 + "github.com/stormlightlabs/noteleaf/internal/shared" 8 + ) 9 + 10 + func TestMarkdownConverter(t *testing.T) { 11 + converter := NewMarkdownConverter() 12 + 13 + t.Run("Conversion", func(t *testing.T) { 14 + t.Run("converts heading to HeaderBlock", func(t *testing.T) { 15 + markdown := "# Hello World" 16 + blocks, err := converter.ToLeaflet(markdown) 17 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 18 + shared.AssertEqual(t, 1, len(blocks), "should have 1 block") 19 + 20 + header, ok := blocks[0].Block.(HeaderBlock) 21 + shared.AssertTrue(t, ok, "block should be HeaderBlock") 22 + shared.AssertEqual(t, TypeHeaderBlock, header.Type, "type should match") 23 + shared.AssertEqual(t, 1, header.Level, "level should be 1") 24 + shared.AssertEqual(t, "Hello World", header.Plaintext, "text should match") 25 + }) 26 + 27 + t.Run("converts multiple heading levels", func(t *testing.T) { 28 + markdown := "## Level 2\n\n### Level 3\n\n###### Level 6" 29 + blocks, err := converter.ToLeaflet(markdown) 30 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 31 + shared.AssertEqual(t, 3, len(blocks), "should have 3 blocks") 32 + 33 + h2 := blocks[0].Block.(HeaderBlock) 34 + shared.AssertEqual(t, 2, h2.Level, "first heading level") 35 + 36 + h3 := blocks[1].Block.(HeaderBlock) 37 + shared.AssertEqual(t, 3, h3.Level, "second heading level") 38 + 39 + h6 := blocks[2].Block.(HeaderBlock) 40 + shared.AssertEqual(t, 6, h6.Level, "third heading level") 41 + }) 42 + 43 + t.Run("converts paragraph to TextBlock", func(t *testing.T) { 44 + markdown := "This is a simple paragraph." 45 + blocks, err := converter.ToLeaflet(markdown) 46 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 47 + shared.AssertEqual(t, 1, len(blocks), "should have 1 block") 48 + 49 + text, ok := blocks[0].Block.(TextBlock) 50 + shared.AssertTrue(t, ok, "block should be TextBlock") 51 + shared.AssertEqual(t, TypeTextBlock, text.Type, "type should match") 52 + shared.AssertEqual(t, "This is a simple paragraph.", text.Plaintext, "text should match") 53 + }) 54 + 55 + t.Run("converts code block to CodeBlock", func(t *testing.T) { 56 + markdown := "```go\nfunc main() {\n}\n```" 57 + blocks, err := converter.ToLeaflet(markdown) 58 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 59 + shared.AssertEqual(t, 1, len(blocks), "should have 1 block") 60 + 61 + code, ok := blocks[0].Block.(CodeBlock) 62 + shared.AssertTrue(t, ok, "block should be CodeBlock") 63 + shared.AssertEqual(t, TypeCodeBlock, code.Type, "type should match") 64 + shared.AssertEqual(t, "go", code.Language, "language should match") 65 + shared.AssertTrue(t, strings.Contains(code.Plaintext, "func main"), "code content should match") 66 + }) 67 + 68 + t.Run("converts blockquote to BlockquoteBlock", func(t *testing.T) { 69 + markdown := "> This is a quote" 70 + blocks, err := converter.ToLeaflet(markdown) 71 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 72 + shared.AssertEqual(t, 1, len(blocks), "should have 1 block") 73 + 74 + quote, ok := blocks[0].Block.(BlockquoteBlock) 75 + shared.AssertTrue(t, ok, "block should be BlockquoteBlock") 76 + shared.AssertEqual(t, TypeBlockquoteBlock, quote.Type, "type should match") 77 + shared.AssertTrue(t, strings.Contains(quote.Plaintext, "This is a quote"), "quote text should match") 78 + }) 79 + 80 + t.Run("converts list to UnorderedListBlock", func(t *testing.T) { 81 + markdown := "- Item 1\n- Item 2\n- Item 3" 82 + blocks, err := converter.ToLeaflet(markdown) 83 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 84 + shared.AssertEqual(t, 1, len(blocks), "should have 1 block") 85 + 86 + list, ok := blocks[0].Block.(UnorderedListBlock) 87 + shared.AssertTrue(t, ok, "block should be UnorderedListBlock") 88 + shared.AssertEqual(t, TypeUnorderedListBlock, list.Type, "type should match") 89 + shared.AssertEqual(t, 3, len(list.Children), "should have 3 items") 90 + 91 + item1 := list.Children[0].Content.(TextBlock) 92 + shared.AssertTrue(t, strings.Contains(item1.Plaintext, "Item 1"), "first item text") 93 + }) 94 + 95 + t.Run("converts horizontal rule to HorizontalRuleBlock", func(t *testing.T) { 96 + markdown := "---" 97 + blocks, err := converter.ToLeaflet(markdown) 98 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 99 + shared.AssertEqual(t, 1, len(blocks), "should have 1 block") 100 + 101 + hr, ok := blocks[0].Block.(HorizontalRuleBlock) 102 + shared.AssertTrue(t, ok, "block should be HorizontalRuleBlock") 103 + shared.AssertEqual(t, TypeHorizontalRuleBlock, hr.Type, "type should match") 104 + }) 105 + 106 + t.Run("converts mixed blocks", func(t *testing.T) { 107 + markdown := `# Title 108 + 109 + This is a paragraph. 110 + 111 + ## Subtitle 112 + 113 + - List item 1 114 + - List item 2 115 + 116 + --- 117 + 118 + ` + "```go\ncode\n```" 119 + 120 + blocks, err := converter.ToLeaflet(markdown) 121 + 122 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 123 + shared.AssertTrue(t, len(blocks) >= 5, "should have multiple blocks") 124 + }) 125 + }) 126 + 127 + t.Run("Facets", func(t *testing.T) { 128 + t.Run("extracts bold facet", func(t *testing.T) { 129 + markdown := "This is **bold** text" 130 + blocks, err := converter.ToLeaflet(markdown) 131 + 132 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 133 + text := blocks[0].Block.(TextBlock) 134 + 135 + shared.AssertTrue(t, len(text.Facets) > 0, "should have facets") 136 + shared.AssertTrue(t, strings.Contains(text.Plaintext, "bold"), "text should contain 'bold'") 137 + }) 138 + 139 + t.Run("extracts italic facet", func(t *testing.T) { 140 + markdown := "This is *italic* text" 141 + blocks, err := converter.ToLeaflet(markdown) 142 + 143 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 144 + text := blocks[0].Block.(TextBlock) 145 + 146 + shared.AssertTrue(t, len(text.Facets) > 0, "should have facets") 147 + }) 148 + 149 + t.Run("extracts inline code facet", func(t *testing.T) { 150 + markdown := "This is `code` text" 151 + blocks, err := converter.ToLeaflet(markdown) 152 + 153 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 154 + text := blocks[0].Block.(TextBlock) 155 + 156 + shared.AssertTrue(t, len(text.Facets) > 0, "should have facets") 157 + shared.AssertTrue(t, strings.Contains(text.Plaintext, "code"), "text should contain 'code'") 158 + }) 159 + 160 + t.Run("extracts link facet", func(t *testing.T) { 161 + markdown := "This is a [link](https://example.com)" 162 + blocks, err := converter.ToLeaflet(markdown) 163 + 164 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 165 + text := blocks[0].Block.(TextBlock) 166 + 167 + shared.AssertTrue(t, len(text.Facets) > 0, "should have facets") 168 + shared.AssertTrue(t, strings.Contains(text.Plaintext, "link"), "text should contain 'link'") 169 + 170 + foundLink := false 171 + for _, facet := range text.Facets { 172 + for _, feature := range facet.Features { 173 + if link, ok := feature.(FacetLink); ok { 174 + shared.AssertEqual(t, "https://example.com", link.URI, "link URI should match") 175 + foundLink = true 176 + } 177 + } 178 + } 179 + shared.AssertTrue(t, foundLink, "should have found link facet") 180 + }) 181 + 182 + t.Run("extracts strikethrough facet", func(t *testing.T) { 183 + markdown := "This is ~~deleted~~ text" 184 + blocks, err := converter.ToLeaflet(markdown) 185 + 186 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 187 + text := blocks[0].Block.(TextBlock) 188 + 189 + shared.AssertTrue(t, len(text.Facets) > 0, "should have facets") 190 + }) 191 + 192 + t.Run("extracts multiple facets", func(t *testing.T) { 193 + markdown := "This has **bold** and *italic* and `code`" 194 + blocks, err := converter.ToLeaflet(markdown) 195 + 196 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 197 + text := blocks[0].Block.(TextBlock) 198 + 199 + shared.AssertTrue(t, len(text.Facets) >= 3, "should have at least 3 facets") 200 + }) 201 + }) 202 + 203 + t.Run("Round-trip Conversion", func(t *testing.T) { 204 + t.Run("heading round-trip", func(t *testing.T) { 205 + original := "## Hello World" 206 + blocks, err := converter.ToLeaflet(original) 207 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 208 + 209 + markdown, err := converter.FromLeaflet(blocks) 210 + shared.AssertNoError(t, err, "FromLeaflet should succeed") 211 + shared.AssertTrue(t, strings.Contains(markdown, "Hello World"), "should contain original text") 212 + shared.AssertTrue(t, strings.HasPrefix(markdown, "##"), "should have heading markers") 213 + }) 214 + 215 + t.Run("text round-trip", func(t *testing.T) { 216 + original := "Simple paragraph" 217 + blocks, err := converter.ToLeaflet(original) 218 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 219 + 220 + markdown, err := converter.FromLeaflet(blocks) 221 + shared.AssertNoError(t, err, "FromLeaflet should succeed") 222 + shared.AssertTrue(t, strings.Contains(markdown, "Simple paragraph"), "should contain original text") 223 + }) 224 + 225 + t.Run("code block round-trip", func(t *testing.T) { 226 + original := "```go\nfunc test() {}\n```" 227 + blocks, err := converter.ToLeaflet(original) 228 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 229 + 230 + markdown, err := converter.FromLeaflet(blocks) 231 + shared.AssertNoError(t, err, "FromLeaflet should succeed") 232 + shared.AssertTrue(t, strings.Contains(markdown, "```"), "should have code fences") 233 + shared.AssertTrue(t, strings.Contains(markdown, "func test"), "should contain code") 234 + }) 235 + 236 + t.Run("list round-trip", func(t *testing.T) { 237 + original := "- Item 1\n- Item 2" 238 + blocks, err := converter.ToLeaflet(original) 239 + shared.AssertNoError(t, err, "ToLeaflet should succeed") 240 + 241 + markdown, err := converter.FromLeaflet(blocks) 242 + shared.AssertNoError(t, err, "FromLeaflet should succeed") 243 + 244 + shared.AssertTrue(t, strings.Contains(markdown, "Item 1"), "should contain first item") 245 + shared.AssertTrue(t, strings.Contains(markdown, "Item 2"), "should contain second item") 246 + shared.AssertTrue(t, strings.Contains(markdown, "-"), "should have list markers") 247 + }) 248 + }) 249 + 250 + t.Run("Edge Cases", func(t *testing.T) { 251 + t.Run("handles empty markdown", func(t *testing.T) { 252 + blocks, err := converter.ToLeaflet("") 253 + shared.AssertNoError(t, err, "should handle empty string") 254 + shared.AssertEqual(t, 0, len(blocks), "should have no blocks") 255 + }) 256 + 257 + t.Run("skips empty paragraphs", func(t *testing.T) { 258 + markdown := "\n\n\n" 259 + blocks, err := converter.ToLeaflet(markdown) 260 + shared.AssertNoError(t, err, "should succeed") 261 + shared.AssertEqual(t, 0, len(blocks), "should skip empty paragraphs") 262 + }) 263 + 264 + t.Run("handles special characters", func(t *testing.T) { 265 + markdown := "Text with *special* characters" 266 + blocks, err := converter.ToLeaflet(markdown) 267 + shared.AssertNoError(t, err, "should handle special characters") 268 + shared.AssertEqual(t, 1, len(blocks), "should have 1 block") 269 + 270 + text := blocks[0].Block.(TextBlock) 271 + shared.AssertTrue(t, strings.Contains(text.Plaintext, "special"), "should preserve text") 272 + shared.AssertTrue(t, strings.Contains(text.Plaintext, "characters"), "should preserve text") 273 + }) 274 + 275 + t.Run("handles multiple paragraphs", func(t *testing.T) { 276 + markdown := "First paragraph\n\nSecond paragraph" 277 + blocks, err := converter.ToLeaflet(markdown) 278 + shared.AssertNoError(t, err, "should succeed") 279 + shared.AssertEqual(t, 2, len(blocks), "should have 2 blocks") 280 + }) 281 + }) 282 + }
+226
internal/public/public.go
··· 1 + // Package public defines leaflet publication schema types 2 + // 3 + // These types correspond to the pub.leaflet.* lexicons used by leaflet.pub 4 + // 5 + // The types here match the lexicon definitions from: 6 + // 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 + TypePublication = "pub.leaflet.publication" 15 + TypeLinearDocument = "pub.leaflet.pages.linearDocument" 16 + TypeBlock = "pub.leaflet.pages.linearDocument#block" 17 + 18 + TypeTextBlock = "pub.leaflet.blocks.text" 19 + TypeHeaderBlock = "pub.leaflet.blocks.header" 20 + TypeCodeBlock = "pub.leaflet.blocks.code" 21 + TypeImageBlock = "pub.leaflet.blocks.image" 22 + TypeBlockquoteBlock = "pub.leaflet.blocks.blockquote" 23 + TypeUnorderedListBlock = "pub.leaflet.blocks.unorderedList" 24 + TypeHorizontalRuleBlock = "pub.leaflet.blocks.horizontalRule" 25 + 26 + TypeFacet = "pub.leaflet.richtext.facet" 27 + TypeByteSlice = "pub.leaflet.richtext.facet#byteSlice" 28 + TypeFacetBold = "pub.leaflet.richtext.facet#bold" 29 + TypeFacetItalic = "pub.leaflet.richtext.facet#italic" 30 + TypeFacetCode = "pub.leaflet.richtext.facet#code" 31 + TypeFacetLink = "pub.leaflet.richtext.facet#link" 32 + TypeFacetStrike = "pub.leaflet.richtext.facet#strikethrough" 33 + TypeFacetUnderline = "pub.leaflet.richtext.facet#underline" 34 + TypeFacetHighlight = "pub.leaflet.richtext.facet#highlight" 35 + 36 + TypeListItem = "pub.leaflet.blocks.unorderedList#listItem" 37 + TypeAspectRatio = "pub.leaflet.blocks.image#aspectRatio" 38 + TypeBlob = "blob" 39 + ) 40 + 41 + // Document represents a leaflet document (pub.leaflet.document) 42 + type Document struct { 43 + Type string `json:"$type"` 44 + Author string `json:"author"` // DID (Decentralized Identifier) 45 + Title string `json:"title"` // Max 128 graphemes 46 + Description string `json:"description"` // Max 300 graphemes 47 + PublishedAt string `json:"publishedAt"` // ISO8601 datetime 48 + Publication string `json:"publication"` // URI: at://did/pub.leaflet.publication/rkey 49 + Pages []LinearDocument `json:"pages"` 50 + } 51 + 52 + // LinearDocument represents a page in a leaflet document (pub.leaflet.pages.linearDocument) 53 + type LinearDocument struct { 54 + Type string `json:"$type"` 55 + ID string `json:"id,omitempty"` 56 + Blocks []BlockWrap `json:"blocks"` 57 + } 58 + 59 + // BlockWrap wraps a block with optional metadata (alignment, etc.) 60 + type BlockWrap struct { 61 + Type string `json:"$type"` 62 + Block any `json:"block"` // One of: TextBlock, HeaderBlock, etc. 63 + Alignment string `json:"alignment,omitempty"` // #textAlignLeft, etc. 64 + } 65 + 66 + // TextBlock represents a text content block (pub.leaflet.blocks.text) 67 + type TextBlock struct { 68 + Type string `json:"$type"` 69 + Plaintext string `json:"plaintext"` 70 + Facets []Facet `json:"facets,omitempty"` 71 + } 72 + 73 + // HeaderBlock represents a heading content block (pub.leaflet.blocks.header) 74 + type HeaderBlock struct { 75 + Type string `json:"$type"` 76 + Level int `json:"level,omitempty"` // h1 - h6 77 + Plaintext string `json:"plaintext"` 78 + Facets []Facet `json:"facets,omitempty"` 79 + } 80 + 81 + // CodeBlock represents a code content block (pub.leaflet.blocks.code) 82 + type CodeBlock struct { 83 + Type string `json:"$type"` 84 + Plaintext string `json:"plaintext"` 85 + Language string `json:"language,omitempty"` 86 + SyntaxHighlightingTheme string `json:"syntaxHighlightingTheme,omitempty"` 87 + } 88 + 89 + // ImageBlock represents an image content block (pub.leaflet.blocks.image) 90 + type ImageBlock struct { 91 + Type string `json:"$type"` 92 + Image Blob `json:"image"` 93 + Alt string `json:"alt,omitempty"` 94 + AspectRatio AspectRatio `json:"aspectRatio"` 95 + } 96 + 97 + // AspectRatio represents image dimensions (pub.leaflet.blocks.image#aspectRatio) 98 + type AspectRatio struct { 99 + Type string `json:"$type"` 100 + Width int `json:"width"` 101 + Height int `json:"height"` 102 + } 103 + 104 + // BlockquoteBlock represents a blockquote content block (pub.leaflet.blocks.blockquote) 105 + type BlockquoteBlock struct { 106 + Type string `json:"$type"` 107 + Plaintext string `json:"plaintext"` 108 + Facets []Facet `json:"facets,omitempty"` 109 + } 110 + 111 + // UnorderedListBlock represents an unordered list (pub.leaflet.blocks.unorderedList) 112 + type UnorderedListBlock struct { 113 + Type string `json:"$type"` 114 + Children []ListItem `json:"children"` 115 + } 116 + 117 + // ListItem represents a single list item (pub.leaflet.blocks.unorderedList#listItem) 118 + type ListItem struct { 119 + Type string `json:"$type"` 120 + Content any `json:"content"` // [TextBlock], [HeaderBlock], [ImageBlock] 121 + Children []ListItem `json:"children,omitempty"` // Nested list items 122 + } 123 + 124 + // HorizontalRuleBlock represents a horizontal rule/thematic break (pub.leaflet.blocks.horizontalRule) 125 + type HorizontalRuleBlock struct { 126 + Type string `json:"$type"` 127 + } 128 + 129 + // Facet represents text annotation (pub.leaflet.richtext.facet) 130 + type Facet struct { 131 + Type string `json:"$type"` 132 + Index ByteSlice `json:"index"` 133 + Features []FacetFeature `json:"features"` 134 + } 135 + 136 + // ByteSlice specifies a substring range using UTF-8 byte offsets (pub.leaflet.richtext.facet#byteSlice) 137 + type ByteSlice struct { 138 + Type string `json:"$type"` 139 + ByteStart int `json:"byteStart"` 140 + ByteEnd int `json:"byteEnd"` 141 + } 142 + 143 + // FacetFeature is a marker interface for facet features 144 + type FacetFeature interface { 145 + GetFacetType() string 146 + } 147 + 148 + // FacetBold represents bold text styling 149 + type FacetBold struct { 150 + Type string `json:"$type"` 151 + } 152 + 153 + func (f FacetBold) GetFacetType() string { return TypeFacetBold } 154 + 155 + // FacetItalic represents italic text styling 156 + type FacetItalic struct { 157 + Type string `json:"$type"` 158 + } 159 + 160 + func (f FacetItalic) GetFacetType() string { return TypeFacetItalic } 161 + 162 + // FacetCode represents inline code styling 163 + type FacetCode struct { 164 + Type string `json:"$type"` 165 + } 166 + 167 + func (f FacetCode) GetFacetType() string { return TypeFacetCode } 168 + 169 + // FacetLink represents a hyperlink 170 + type FacetLink struct { 171 + Type string `json:"$type"` 172 + URI string `json:"uri"` 173 + } 174 + 175 + func (f FacetLink) GetFacetType() string { return TypeFacetLink } 176 + 177 + // FacetStrikethrough represents strikethrough text styling 178 + type FacetStrikethrough struct { 179 + Type string `json:"$type"` 180 + } 181 + 182 + func (f FacetStrikethrough) GetFacetType() string { return TypeFacetStrike } 183 + 184 + // FacetUnderline represents underline text styling 185 + type FacetUnderline struct { 186 + Type string `json:"$type"` 187 + } 188 + 189 + func (f FacetUnderline) GetFacetType() string { return TypeFacetUnderline } 190 + 191 + // FacetHighlight represents highlighted text 192 + type FacetHighlight struct { 193 + Type string `json:"$type"` 194 + } 195 + 196 + func (f FacetHighlight) GetFacetType() string { return TypeFacetHighlight } 197 + 198 + // Blob represents binary content (images, files) 199 + type Blob struct { 200 + Type string `json:"$type"` 201 + Ref CID `json:"ref"` 202 + MimeType string `json:"mimeType"` 203 + Size int `json:"size"` 204 + } 205 + 206 + // CID represents a Content Identifier (IPFS CID) 207 + type CID struct { 208 + Link string `json:"$link"` 209 + } 210 + 211 + // Publication represents a leaflet publication (pub.leaflet.publication) 212 + type Publication struct { 213 + Type string `json:"$type"` 214 + Name string `json:"name"` 215 + Description string `json:"description,omitempty"` 216 + CreatedAt time.Time `json:"createdAt"` 217 + } 218 + 219 + // DocumentMeta holds metadata about a fetched document 220 + type DocumentMeta struct { 221 + RKey string // Record key (TID) 222 + CID string // Content identifier 223 + URI string // Full AT URI 224 + IsDraft bool // Draft vs published 225 + FetchedAt time.Time // When we fetched it 226 + }
+30 -3
internal/repo/note_repository.go
··· 39 39 var note models.Note 40 40 var tags string 41 41 err := s.Scan(&note.ID, &note.Title, &note.Content, &tags, &note.Archived, 42 - &note.Created, &note.Modified, &note.FilePath) 42 + &note.Created, &note.Modified, &note.FilePath, &note.LeafletRKey, 43 + &note.LeafletCID, &note.PublishedAt, &note.IsDraft) 43 44 if err != nil { 44 45 return nil, err 45 46 } ··· 98 99 } 99 100 100 101 result, err := r.db.ExecContext(ctx, queryNoteInsert, 101 - note.Title, note.Content, tags, note.Archived, note.Created, note.Modified, note.FilePath) 102 + note.Title, note.Content, tags, note.Archived, note.Created, note.Modified, note.FilePath, 103 + note.LeafletRKey, note.LeafletCID, note.PublishedAt, note.IsDraft) 102 104 if err != nil { 103 105 return 0, fmt.Errorf("failed to insert note: %w", err) 104 106 } ··· 131 133 } 132 134 133 135 result, err := r.db.ExecContext(ctx, queryNoteUpdate, 134 - note.Title, note.Content, tags, note.Archived, note.Modified, note.FilePath, note.ID) 136 + note.Title, note.Content, tags, note.Archived, note.Modified, note.FilePath, 137 + note.LeafletRKey, note.LeafletCID, note.PublishedAt, note.IsDraft, note.ID) 135 138 if err != nil { 136 139 return fmt.Errorf("failed to update note: %w", err) 137 140 } ··· 306 309 query, args := r.buildTagsQuery(tags) 307 310 return r.queryMany(ctx, query, args...) 308 311 } 312 + 313 + // GetByLeafletRKey returns a note by its leaflet record key 314 + func (r *NoteRepository) GetByLeafletRKey(ctx context.Context, rkey string) (*models.Note, error) { 315 + query := "SELECT " + noteColumns + " FROM notes WHERE leaflet_rkey = ?" 316 + return r.queryOne(ctx, query, rkey) 317 + } 318 + 319 + // ListPublished returns all published leaflet notes (not drafts) 320 + func (r *NoteRepository) ListPublished(ctx context.Context) ([]*models.Note, error) { 321 + query := "SELECT " + noteColumns + " FROM notes WHERE leaflet_rkey IS NOT NULL AND is_draft = false ORDER BY published_at DESC" 322 + return r.queryMany(ctx, query) 323 + } 324 + 325 + // ListDrafts returns all draft leaflet notes 326 + func (r *NoteRepository) ListDrafts(ctx context.Context) ([]*models.Note, error) { 327 + query := "SELECT " + noteColumns + " FROM notes WHERE leaflet_rkey IS NOT NULL AND is_draft = true ORDER BY modified DESC" 328 + return r.queryMany(ctx, query) 329 + } 330 + 331 + // GetLeafletNotes returns all notes with leaflet association (both published and drafts) 332 + func (r *NoteRepository) GetLeafletNotes(ctx context.Context) ([]*models.Note, error) { 333 + query := "SELECT " + noteColumns + " FROM notes WHERE leaflet_rkey IS NOT NULL ORDER BY modified DESC" 334 + return r.queryMany(ctx, query) 335 + }
+114
internal/repo/note_repository_test.go
··· 3 3 import ( 4 4 "context" 5 5 "testing" 6 + "time" 6 7 7 8 _ "github.com/mattn/go-sqlite3" 8 9 "github.com/stormlightlabs/noteleaf/internal/models" ··· 513 514 notes, err := repo.List(ctx, NoteListOptions{Title: "NonexistentTitle"}) 514 515 shared.AssertNoError(t, err, "Should not error when no notes found") 515 516 shared.AssertEqual(t, 0, len(notes), "Expected empty result set") 517 + }) 518 + }) 519 + 520 + t.Run("Leaflet Methods", func(t *testing.T) { 521 + db := CreateTestDB(t) 522 + repo := NewNoteRepository(db) 523 + ctx := context.Background() 524 + 525 + rkey1 := "rkey-published-1" 526 + rkey2 := "rkey-draft-1" 527 + rkey3 := "rkey-published-2" 528 + pubTime := time.Now() 529 + 530 + publishedNote1 := &models.Note{ 531 + Title: "Published Note 1", 532 + Content: "Content 1", 533 + LeafletRKey: &rkey1, 534 + IsDraft: false, 535 + PublishedAt: &pubTime, 536 + } 537 + _, err := repo.Create(ctx, publishedNote1) 538 + shared.AssertNoError(t, err, "create published note 1") 539 + 540 + draftNote := &models.Note{ 541 + Title: "Draft Note", 542 + Content: "Draft content", 543 + LeafletRKey: &rkey2, 544 + IsDraft: true, 545 + } 546 + _, err = repo.Create(ctx, draftNote) 547 + shared.AssertNoError(t, err, "create draft note") 548 + 549 + publishedNote2 := &models.Note{ 550 + Title: "Published Note 2", 551 + Content: "Content 2", 552 + LeafletRKey: &rkey3, 553 + IsDraft: false, 554 + PublishedAt: &pubTime, 555 + } 556 + _, err = repo.Create(ctx, publishedNote2) 557 + shared.AssertNoError(t, err, "create published note 2") 558 + 559 + regularNote := &models.Note{ 560 + Title: "Regular Note", 561 + Content: "No leaflet association", 562 + } 563 + _, err = repo.Create(ctx, regularNote) 564 + shared.AssertNoError(t, err, "create regular note") 565 + 566 + t.Run("GetByLeafletRKey finds note by rkey", func(t *testing.T) { 567 + note, err := repo.GetByLeafletRKey(ctx, rkey1) 568 + shared.AssertNoError(t, err, "get by leaflet rkey") 569 + shared.AssertEqual(t, "Published Note 1", note.Title, "title should match") 570 + shared.AssertNotNil(t, note.LeafletRKey, "leaflet rkey should be set") 571 + shared.AssertEqual(t, rkey1, *note.LeafletRKey, "rkey should match") 572 + }) 573 + 574 + t.Run("GetByLeafletRKey returns error for non-existent rkey", func(t *testing.T) { 575 + _, err := repo.GetByLeafletRKey(ctx, "non-existent-rkey") 576 + shared.AssertError(t, err, "should error for non-existent rkey") 577 + }) 578 + 579 + t.Run("ListPublished returns only published notes", func(t *testing.T) { 580 + notes, err := repo.ListPublished(ctx) 581 + shared.AssertNoError(t, err, "list published") 582 + shared.AssertEqual(t, 2, len(notes), "should have 2 published notes") 583 + 584 + for _, note := range notes { 585 + shared.AssertFalse(t, note.IsDraft, "published notes should not be drafts") 586 + shared.AssertNotNil(t, note.LeafletRKey, "should have leaflet rkey") 587 + } 588 + }) 589 + 590 + t.Run("ListDrafts returns only draft notes", func(t *testing.T) { 591 + notes, err := repo.ListDrafts(ctx) 592 + shared.AssertNoError(t, err, "list drafts") 593 + shared.AssertEqual(t, 1, len(notes), "should have 1 draft note") 594 + 595 + shared.AssertTrue(t, notes[0].IsDraft, "should be draft") 596 + shared.AssertEqual(t, "Draft Note", notes[0].Title, "title should match") 597 + shared.AssertNotNil(t, notes[0].LeafletRKey, "should have leaflet rkey") 598 + }) 599 + 600 + t.Run("GetLeafletNotes returns all notes with leaflet association", func(t *testing.T) { 601 + notes, err := repo.GetLeafletNotes(ctx) 602 + shared.AssertNoError(t, err, "get leaflet notes") 603 + shared.AssertEqual(t, 3, len(notes), "should have 3 leaflet notes") 604 + 605 + for _, note := range notes { 606 + shared.AssertNotNil(t, note.LeafletRKey, "all should have leaflet rkey") 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) 613 + AssertCancelledContext(t, err) 614 + }) 615 + 616 + t.Run("ListPublished with cancelled context", func(t *testing.T) { 617 + _, err := repo.ListPublished(NewCanceledContext()) 618 + AssertCancelledContext(t, err) 619 + }) 620 + 621 + t.Run("ListDrafts with cancelled context", func(t *testing.T) { 622 + _, err := repo.ListDrafts(NewCanceledContext()) 623 + AssertCancelledContext(t, err) 624 + }) 625 + 626 + t.Run("GetLeafletNotes with cancelled context", func(t *testing.T) { 627 + _, err := repo.GetLeafletNotes(NewCanceledContext()) 628 + AssertCancelledContext(t, err) 629 + }) 516 630 }) 517 631 }) 518 632 }
+3 -3
internal/repo/queries.go
··· 1 1 package repo 2 2 3 3 const ( 4 - noteColumns = "id, title, content, tags, archived, created, modified, file_path" 4 + noteColumns = "id, title, content, tags, archived, created, modified, file_path, leaflet_rkey, leaflet_cid, published_at, is_draft" 5 5 queryNoteByID = "SELECT " + noteColumns + " FROM notes WHERE id = ?" 6 - queryNoteInsert = `INSERT INTO notes (title, content, tags, archived, created, modified, file_path) VALUES (?, ?, ?, ?, ?, ?, ?)` 7 - queryNoteUpdate = `UPDATE notes SET title = ?, content = ?, tags = ?, archived = ?, modified = ?, file_path = ? WHERE id = ?` 6 + queryNoteInsert = `INSERT INTO notes (title, content, tags, archived, created, modified, file_path, leaflet_rkey, leaflet_cid, published_at, is_draft) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` 7 + queryNoteUpdate = `UPDATE notes SET title = ?, content = ?, tags = ?, archived = ?, modified = ?, file_path = ?, leaflet_rkey = ?, leaflet_cid = ?, published_at = ?, is_draft = ? WHERE id = ?` 8 8 queryNoteDelete = "DELETE FROM notes WHERE id = ?" 9 9 queryNotesList = "SELECT " + noteColumns + " FROM notes" 10 10 )
+136
internal/services/atproto.go
··· 1 + // TODO: Implement authentication using indigo's xrpc client: 2 + // 1. Create session via com.atproto.server.createSession 3 + // 2. Store session tokens 4 + // 3. Handle token refresh 5 + // 6 + // TODO: Implement authentication 7 + // 1. Create XRPC client 8 + // 2. Call com.atproto.server.createSession 9 + // 3. Parse response and store session 10 + // 4. Resolve PDS URL from DID 11 + // 12 + // TODO: Implement document fetching: 13 + // 1. Call com.atproto.sync.getRepo to get repository CAR file 14 + // 2. Parse CAR (Content Addressable aRchive) format 15 + // 3. Filter records by collection: pub.leaflet.document 16 + // 4. Extract documents and metadata 17 + // 5. Return as []DocumentWithMeta 18 + // 19 + // TODO: Implement document pulling 20 + // 1. GET {pdsURL}/xrpc/com.atproto.sync.getRepo?did={session.DID} 21 + // 2. Parse CAR stream using github.com/bluesky-social/indigo/repo 22 + // 3. Iterate over records, filter by collection == "pub.leaflet.document" 23 + // 4. Parse each record as public.Document 24 + // 5. Collect metadata (rkey, cid, uri) 25 + // 26 + // TODO: Implement publication listing: 27 + // 1. Query records with collection: pub.leaflet.publication 28 + // 2. Parse as public.Publication 29 + // 3. Return list 30 + // 31 + // TODO: Implement session clearing: close any open connections 32 + package services 33 + 34 + import ( 35 + "context" 36 + "fmt" 37 + "time" 38 + 39 + "github.com/stormlightlabs/noteleaf/internal/public" 40 + ) 41 + 42 + // DocumentWithMeta combines a document with its repository metadata 43 + type DocumentWithMeta struct { 44 + Document public.Document 45 + Meta public.DocumentMeta 46 + } 47 + 48 + // PublicationWithMeta combines a publication with its metadata 49 + type PublicationWithMeta struct { 50 + Publication public.Publication 51 + RKey string 52 + CID string 53 + URI string 54 + } 55 + 56 + // Session holds authentication session information 57 + type Session struct { 58 + DID string // Decentralized Identifier 59 + Handle string // User handle (e.g., username.bsky.social) 60 + AccessJWT string // Access token 61 + RefreshJWT string // Refresh token 62 + PDSURL string // Personal Data Server URL 63 + ExpiresAt time.Time // When access token expires 64 + Authenticated bool // Whether session is valid 65 + } 66 + 67 + // ATProtoService provides AT Protocol operations for leaflet integration 68 + type ATProtoService struct { 69 + handle string 70 + password string 71 + session *Session 72 + pdsURL string // Personal Data Server URL 73 + // TODO: wrap AT Protocol client from indigo package 74 + // client *xrpc.Client 75 + } 76 + 77 + // NewATProtoService creates a new AT Protocol service 78 + func NewATProtoService() *ATProtoService { 79 + return &ATProtoService{ 80 + pdsURL: "https://bsky.social", 81 + } 82 + } 83 + 84 + // Authenticate logs in with BlueSky/AT Protocol credentials 85 + func (s *ATProtoService) Authenticate(ctx context.Context, handle, password string) error { 86 + if handle == "" || password == "" { 87 + return fmt.Errorf("handle and password are required") 88 + } 89 + 90 + s.handle = handle 91 + s.password = password 92 + s.session = &Session{ 93 + Handle: handle, 94 + // TODO: Set to true once auth is implemented 95 + Authenticated: false, 96 + PDSURL: s.pdsURL, 97 + } 98 + 99 + return fmt.Errorf("TODO: implement com.atproto.server.createSession") 100 + } 101 + 102 + // GetSession returns the current session information 103 + func (s *ATProtoService) GetSession() (*Session, error) { 104 + if s.session == nil || !s.session.Authenticated { 105 + return nil, fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 106 + } 107 + return s.session, nil 108 + } 109 + 110 + // IsAuthenticated checks if the service has a valid session 111 + func (s *ATProtoService) IsAuthenticated() bool { 112 + return s.session != nil && s.session.Authenticated 113 + } 114 + 115 + // PullDocuments fetches all leaflet documents from the user's repository 116 + func (s *ATProtoService) PullDocuments(ctx context.Context) ([]DocumentWithMeta, error) { 117 + if !s.IsAuthenticated() { 118 + return nil, fmt.Errorf("not authenticated") 119 + } 120 + 121 + return nil, fmt.Errorf("document pulling not yet implemented - TODO: implement com.atproto.sync.getRepo with CAR parsing") 122 + } 123 + 124 + // ListPublications fetches available publications for the authenticated user 125 + func (s *ATProtoService) ListPublications(ctx context.Context) ([]PublicationWithMeta, error) { 126 + if !s.IsAuthenticated() { 127 + return nil, fmt.Errorf("not authenticated") 128 + } 129 + return nil, fmt.Errorf("publication listing not yet implemented") 130 + } 131 + 132 + // Close cleans up resources 133 + func (s *ATProtoService) Close() error { 134 + s.session = nil 135 + return nil 136 + }
+7
internal/store/sql/migrations/0008_add_leaflet_fields_to_notes_down.sql
··· 1 + -- Remove leaflet fields and indexes 2 + DROP INDEX IF EXISTS idx_notes_is_draft; 3 + DROP INDEX IF EXISTS idx_notes_leaflet_rkey; 4 + ALTER TABLE notes DROP COLUMN is_draft; 5 + ALTER TABLE notes DROP COLUMN published_at; 6 + ALTER TABLE notes DROP COLUMN leaflet_cid; 7 + ALTER TABLE notes DROP COLUMN leaflet_rkey;
+11
internal/store/sql/migrations/0008_add_leaflet_fields_to_notes_up.sql
··· 1 + -- Add leaflet publication fields to notes table 2 + ALTER TABLE notes ADD COLUMN leaflet_rkey TEXT; 3 + ALTER TABLE notes ADD COLUMN leaflet_cid TEXT; 4 + ALTER TABLE notes ADD COLUMN published_at DATETIME; 5 + ALTER TABLE notes ADD COLUMN is_draft INTEGER DEFAULT 0; 6 + 7 + -- Add index for leaflet record key lookups 8 + CREATE INDEX IF NOT EXISTS idx_notes_leaflet_rkey ON notes(leaflet_rkey); 9 + 10 + -- Add index for published vs draft queries 11 + CREATE INDEX IF NOT EXISTS idx_notes_is_draft ON notes(is_draft);