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

feat: added UploadBlob method to ATProtoService for uploading binary data

* added PublicationListAdapter for managing publications with leaflet metadata

+1363 -100
+46 -4
cmd/publication_commands.go
··· 103 103 root.AddCommand(pullCmd) 104 104 105 105 listCmd := &cobra.Command{ 106 - Use: "list [--published|--draft|--all]", 106 + Use: "list [--published|--draft|--all] [--interactive]", 107 107 Short: "List notes synced with leaflet", 108 108 Aliases: []string{"ls"}, 109 109 Long: `Display notes that have been pulled from or pushed to leaflet. ··· 115 115 - Content identifier (cid) for change tracking 116 116 117 117 Use filters to show specific subsets: 118 - --published Show only published documents 119 - --draft Show only drafts 120 - --all Show all leaflet documents (default)`, 118 + --published Show only published documents 119 + --draft Show only drafts 120 + --all Show all leaflet documents (default) 121 + --interactive Open interactive TUI browser with search and preview`, 121 122 RunE: func(cmd *cobra.Command, args []string) error { 122 123 published, _ := cmd.Flags().GetBool("published") 123 124 draft, _ := cmd.Flags().GetBool("draft") 124 125 all, _ := cmd.Flags().GetBool("all") 126 + interactive, _ := cmd.Flags().GetBool("interactive") 125 127 126 128 filter := "all" 127 129 if published { ··· 133 135 } 134 136 135 137 defer c.handler.Close() 138 + 139 + if interactive { 140 + return c.handler.Browse(cmd.Context(), filter) 141 + } 142 + 136 143 return c.handler.List(cmd.Context(), filter) 137 144 }, 138 145 } 139 146 listCmd.Flags().Bool("published", false, "Show only published documents") 140 147 listCmd.Flags().Bool("draft", false, "Show only drafts") 141 148 listCmd.Flags().Bool("all", false, "Show all leaflet documents") 149 + listCmd.Flags().BoolP("interactive", "i", false, "Open interactive TUI browser") 142 150 root.AddCommand(listCmd) 143 151 144 152 statusCmd := &cobra.Command{ ··· 239 247 patchCmd.Flags().Bool("preview", false, "Show what would be updated without actually patching") 240 248 patchCmd.Flags().Bool("validate", false, "Validate markdown conversion without patching") 241 249 root.AddCommand(patchCmd) 250 + 251 + pushCmd := &cobra.Command{ 252 + Use: "push [note-ids...]", 253 + Short: "Create or update multiple documents on leaflet", 254 + Long: `Batch publish or update multiple local notes to leaflet.pub. 255 + 256 + For each note: 257 + - If the note has never been published, creates a new document (like post) 258 + - If the note has been published before, updates the existing document (like patch) 259 + 260 + This is useful for bulk operations and continuous publishing workflows. 261 + 262 + Examples: 263 + noteleaf pub push 1 2 3 # Publish/update notes 1, 2, and 3 264 + noteleaf pub push 42 99 --draft # Create/update as drafts`, 265 + Args: cobra.MinimumNArgs(1), 266 + RunE: func(cmd *cobra.Command, args []string) error { 267 + noteIDs := make([]int64, len(args)) 268 + for i, arg := range args { 269 + id, err := parseNoteID(arg) 270 + if err != nil { 271 + return err 272 + } 273 + noteIDs[i] = id 274 + } 275 + 276 + isDraft, _ := cmd.Flags().GetBool("draft") 277 + 278 + defer c.handler.Close() 279 + return c.handler.Push(cmd.Context(), noteIDs, isDraft) 280 + }, 281 + } 282 + pushCmd.Flags().Bool("draft", false, "Create/update as drafts instead of publishing") 283 + root.AddCommand(pushCmd) 242 284 243 285 return root 244 286 }
+2 -1
cmd/publication_commands_test.go
··· 64 64 expectedSubcommands := []string{ 65 65 "auth [handle]", 66 66 "pull", 67 - "list [--published|--draft|--all]", 67 + "list [--published|--draft|--all] [--interactive]", 68 68 "status", 69 69 "post [note-id]", 70 70 "patch [note-id]", 71 + "push [note-ids...]", 71 72 } 72 73 73 74 for _, expected := range expectedSubcommands {
+1 -1
internal/docs/ROADMAP.md
··· 71 71 72 72 ### Configuration & Data 73 73 74 - - [ ] Implement **config management** (flagged TODO in code). 74 + - [ ] Implement **config management** 75 75 - [ ] Define config file format (TOML, YAML, JSON). 76 76 - [ ] Set default config/data paths: 77 77 - Linux: `~/.config/noteleaf`, `~/.local/share/noteleaf`
-1
internal/handlers/articles.go
··· 1 - // TODO: more article sanitizing 2 1 package handlers 3 2 4 3 import (
+90 -24
internal/handlers/publication.go
··· 1 1 // Package handlers provides command handlers for leaflet publication operations. 2 - // 3 - // TODO: Post (create 1) 4 - // TODO: Patch (update 1) 5 - // TODO: Push 6 - // - Builds on Post & Patch (create or update - more than 1) 7 - // 8 - // TODO: Add TUI viewing for document details 9 - // - interactive list, read Markdown with glamour 10 - // 11 - // TODO: Add TUI for confirming post 12 - // - proofread post with glamour 13 - // 14 - // TODO: Repost - "Reblog" - post to BlueSky 15 2 package handlers 16 3 17 4 import ( 18 5 "context" 19 6 "fmt" 7 + "path/filepath" 20 8 "time" 21 9 22 10 "github.com/stormlightlabs/noteleaf/internal/models" ··· 290 278 return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 291 279 } 292 280 293 - // TODO: Implement image handling for markdown conversion 294 - // 1. Extract note's directory from filepath/database 295 - // 2. Create LocalImageResolver with BlobUploader that calls h.atproto.UploadBlob() 296 - // 3. Use converter.WithImageResolver(resolver, noteDir) before ToLeaflet() 297 - // This will upload images to AT Protocol and get real CIDs/dimensions 298 281 note, doc, err := h.prepareDocumentForPublish(ctx, noteID, isDraft, false) 299 282 if err != nil { 300 283 return err ··· 344 327 return fmt.Errorf("failed to get note: %w", err) 345 328 } 346 329 347 - // TODO: Implement image handling for markdown conversion (same as Post method) 348 - // 1. Extract note's directory from filepath/database 349 - // 2. Create LocalImageResolver with BlobUploader that calls h.atproto.UploadBlob() 350 - // 3. Use converter.WithImageResolver(resolver, noteDir) before ToLeaflet() 351 - // This will upload images to AT Protocol and get real CIDs/dimensions 352 330 note, doc, err := h.prepareDocumentForPublish(ctx, noteID, tempNote.IsDraft, true) 353 331 if err != nil { 354 332 return err 355 333 } 356 334 357 - // Update note.PublishedAt if we set a new timestamp 358 335 if !note.IsDraft && note.PublishedAt == nil && doc.PublishedAt != "" { 359 336 publishedAt, err := time.Parse(time.RFC3339, doc.PublishedAt) 360 337 if err == nil { ··· 418 395 return nil 419 396 } 420 397 398 + // Push creates or updates multiple documents on leaflet from local notes 399 + func (h *PublicationHandler) Push(ctx context.Context, noteIDs []int64, isDraft bool) error { 400 + if !h.atproto.IsAuthenticated() { 401 + return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 402 + } 403 + 404 + if len(noteIDs) == 0 { 405 + return fmt.Errorf("no note IDs provided") 406 + } 407 + 408 + ui.Infoln("Processing %d note(s)...\n", len(noteIDs)) 409 + 410 + var created, updated, failed int 411 + var errors []string 412 + 413 + for _, noteID := range noteIDs { 414 + note, err := h.repos.Notes.Get(ctx, noteID) 415 + if err != nil { 416 + ui.Warningln(" [%d] Failed to get note: %v", noteID, err) 417 + errors = append(errors, fmt.Sprintf("note %d: %v", noteID, err)) 418 + failed++ 419 + continue 420 + } 421 + 422 + if note.HasLeafletAssociation() { 423 + err = h.Patch(ctx, noteID) 424 + if err != nil { 425 + ui.Warningln(" [%d] Failed to update '%s': %v", noteID, note.Title, err) 426 + errors = append(errors, fmt.Sprintf("note %d (%s): %v", noteID, note.Title, err)) 427 + failed++ 428 + } else { 429 + updated++ 430 + } 431 + } else { 432 + err = h.Post(ctx, noteID, isDraft) 433 + if err != nil { 434 + ui.Warningln(" [%d] Failed to create '%s': %v", noteID, note.Title, err) 435 + errors = append(errors, fmt.Sprintf("note %d (%s): %v", noteID, note.Title, err)) 436 + failed++ 437 + } else { 438 + created++ 439 + } 440 + } 441 + } 442 + 443 + ui.Newline() 444 + ui.Successln("Push complete: %d created, %d updated, %d failed", created, updated, failed) 445 + 446 + if len(errors) > 0 { 447 + return fmt.Errorf("push completed with %d error(s)", failed) 448 + } 449 + 450 + return nil 451 + } 452 + 453 + // Browse opens an interactive TUI for browsing publications 454 + func (h *PublicationHandler) Browse(ctx context.Context, filter string) error { 455 + if filter == "" { 456 + filter = "all" 457 + } 458 + 459 + opts := ui.DataListOptions{ 460 + Title: "Publications - " + filter, 461 + } 462 + 463 + list := ui.NewPublicationDataList(h.repos.Notes, opts, filter) 464 + return list.Browse(ctx) 465 + } 466 + 421 467 // prepareDocumentForPublish prepares a note for publication by converting to Leaflet format 422 468 func (h *PublicationHandler) prepareDocumentForPublish(ctx context.Context, noteID int64, isDraft bool, forPatch bool) (*models.Note, *public.Document, error) { 423 469 note, err := h.repos.Notes.Get(ctx, noteID) ··· 439 485 } 440 486 441 487 converter := public.NewMarkdownConverter() 488 + 489 + noteDir := extractNoteDirectory(note) 490 + if noteDir != "" { 491 + imageResolver := &public.LocalImageResolver{ 492 + BlobUploader: func(data []byte, mimeType string) (public.Blob, error) { 493 + return h.atproto.UploadBlob(ctx, data, mimeType) 494 + }, 495 + } 496 + converter = converter.WithImageResolver(imageResolver, noteDir) 497 + } 498 + 442 499 blocks, err := converter.ToLeaflet(note.Content) 443 500 if err != nil { 444 501 return nil, nil, fmt.Errorf("failed to convert markdown to leaflet format: %w", err) ··· 587 644 return "Authenticated (session details unavailable)" 588 645 } 589 646 return "Not authenticated" 647 + } 648 + 649 + // extractNoteDirectory extracts the directory path from a note's FilePath 650 + func extractNoteDirectory(note *models.Note) string { 651 + if note.FilePath == "" { 652 + return "" 653 + } 654 + 655 + return filepath.Dir(note.FilePath) 590 656 } 591 657 592 658 // sessionFromConfig converts config AT Protocol fields to a Session
+315
internal/handlers/publication_test.go
··· 1652 1652 } 1653 1653 }) 1654 1654 }) 1655 + 1656 + t.Run("Push", func(t *testing.T) { 1657 + t.Run("returns error when not authenticated", func(t *testing.T) { 1658 + suite := NewHandlerTestSuite(t) 1659 + defer suite.Cleanup() 1660 + 1661 + handler := CreateHandler(t, NewPublicationHandler) 1662 + ctx := context.Background() 1663 + 1664 + err := handler.Push(ctx, []int64{1, 2, 3}, false) 1665 + if err == nil { 1666 + t.Error("Expected error when not authenticated") 1667 + } 1668 + 1669 + if err != nil && !strings.Contains(err.Error(), "not authenticated") { 1670 + t.Errorf("Expected 'not authenticated' error, got '%v'", err) 1671 + } 1672 + }) 1673 + 1674 + t.Run("returns error when no note IDs provided", func(t *testing.T) { 1675 + suite := NewHandlerTestSuite(t) 1676 + defer suite.Cleanup() 1677 + 1678 + handler := CreateHandler(t, NewPublicationHandler) 1679 + ctx := context.Background() 1680 + 1681 + session := &services.Session{ 1682 + DID: "did:plc:test123", 1683 + Handle: "test.bsky.social", 1684 + AccessJWT: "access_token", 1685 + RefreshJWT: "refresh_token", 1686 + PDSURL: "https://bsky.social", 1687 + ExpiresAt: time.Now().Add(2 * time.Hour), 1688 + Authenticated: true, 1689 + } 1690 + 1691 + err := handler.atproto.RestoreSession(session) 1692 + if err != nil { 1693 + t.Fatalf("Failed to restore session: %v", err) 1694 + } 1695 + 1696 + err = handler.Push(ctx, []int64{}, false) 1697 + if err == nil { 1698 + t.Error("Expected error when no note IDs provided") 1699 + } 1700 + 1701 + if err != nil && !strings.Contains(err.Error(), "no note IDs provided") { 1702 + t.Errorf("Expected 'no note IDs provided' error, got '%v'", err) 1703 + } 1704 + }) 1705 + 1706 + t.Run("handles note not found error", func(t *testing.T) { 1707 + suite := NewHandlerTestSuite(t) 1708 + defer suite.Cleanup() 1709 + 1710 + handler := CreateHandler(t, NewPublicationHandler) 1711 + ctx := context.Background() 1712 + 1713 + session := &services.Session{ 1714 + DID: "did:plc:test123", 1715 + Handle: "test.bsky.social", 1716 + AccessJWT: "access_token", 1717 + RefreshJWT: "refresh_token", 1718 + PDSURL: "https://bsky.social", 1719 + ExpiresAt: time.Now().Add(2 * time.Hour), 1720 + Authenticated: true, 1721 + } 1722 + 1723 + err := handler.atproto.RestoreSession(session) 1724 + if err != nil { 1725 + t.Fatalf("Failed to restore session: %v", err) 1726 + } 1727 + 1728 + err = handler.Push(ctx, []int64{999}, false) 1729 + if err == nil { 1730 + t.Error("Expected error when note not found") 1731 + } 1732 + 1733 + if err != nil && !strings.Contains(err.Error(), "error(s)") { 1734 + t.Errorf("Expected error about failures, got '%v'", err) 1735 + } 1736 + }) 1737 + 1738 + t.Run("attempts to create notes without leaflet association", func(t *testing.T) { 1739 + suite := NewHandlerTestSuite(t) 1740 + defer suite.Cleanup() 1741 + 1742 + handler := CreateHandler(t, NewPublicationHandler) 1743 + ctx := context.Background() 1744 + 1745 + note1 := &models.Note{ 1746 + Title: "New Note 1", 1747 + Content: "# Content 1", 1748 + } 1749 + note2 := &models.Note{ 1750 + Title: "New Note 2", 1751 + Content: "# Content 2", 1752 + } 1753 + 1754 + id1, err := handler.repos.Notes.Create(ctx, note1) 1755 + suite.AssertNoError(err, "create note 1") 1756 + 1757 + id2, err := handler.repos.Notes.Create(ctx, note2) 1758 + suite.AssertNoError(err, "create note 2") 1759 + 1760 + session := &services.Session{ 1761 + DID: "did:plc:test123", 1762 + Handle: "test.bsky.social", 1763 + AccessJWT: "access_token", 1764 + RefreshJWT: "refresh_token", 1765 + PDSURL: "https://bsky.social", 1766 + ExpiresAt: time.Now().Add(2 * time.Hour), 1767 + Authenticated: true, 1768 + } 1769 + 1770 + err = handler.atproto.RestoreSession(session) 1771 + if err != nil { 1772 + t.Fatalf("Failed to restore session: %v", err) 1773 + } 1774 + 1775 + err = handler.Push(ctx, []int64{id1, id2}, false) 1776 + 1777 + if err != nil && !strings.Contains(err.Error(), "not authenticated") { 1778 + t.Logf("Got error during push (expected for external service call): %v", err) 1779 + } 1780 + }) 1781 + 1782 + t.Run("attempts to update notes with leaflet association", func(t *testing.T) { 1783 + suite := NewHandlerTestSuite(t) 1784 + defer suite.Cleanup() 1785 + 1786 + handler := CreateHandler(t, NewPublicationHandler) 1787 + ctx := context.Background() 1788 + 1789 + rkey1 := "rkey1" 1790 + cid1 := "cid1" 1791 + publishedAt1 := time.Now().Add(-24 * time.Hour) 1792 + note1 := &models.Note{ 1793 + Title: "Published Note 1", 1794 + Content: "# Content 1", 1795 + LeafletRKey: &rkey1, 1796 + LeafletCID: &cid1, 1797 + PublishedAt: &publishedAt1, 1798 + IsDraft: false, 1799 + } 1800 + 1801 + rkey2 := "rkey2" 1802 + cid2 := "cid2" 1803 + note2 := &models.Note{ 1804 + Title: "Draft Note 2", 1805 + Content: "# Content 2", 1806 + LeafletRKey: &rkey2, 1807 + LeafletCID: &cid2, 1808 + IsDraft: true, 1809 + } 1810 + 1811 + id1, err := handler.repos.Notes.Create(ctx, note1) 1812 + suite.AssertNoError(err, "create note 1") 1813 + 1814 + id2, err := handler.repos.Notes.Create(ctx, note2) 1815 + suite.AssertNoError(err, "create note 2") 1816 + 1817 + session := &services.Session{ 1818 + DID: "did:plc:test123", 1819 + Handle: "test.bsky.social", 1820 + AccessJWT: "access_token", 1821 + RefreshJWT: "refresh_token", 1822 + PDSURL: "https://bsky.social", 1823 + ExpiresAt: time.Now().Add(2 * time.Hour), 1824 + Authenticated: true, 1825 + } 1826 + 1827 + err = handler.atproto.RestoreSession(session) 1828 + if err != nil { 1829 + t.Fatalf("Failed to restore session: %v", err) 1830 + } 1831 + 1832 + err = handler.Push(ctx, []int64{id1, id2}, false) 1833 + 1834 + if err != nil && !strings.Contains(err.Error(), "not authenticated") { 1835 + t.Logf("Got error during push (expected for external service call): %v", err) 1836 + } 1837 + }) 1838 + 1839 + t.Run("handles mixed create and update operations", func(t *testing.T) { 1840 + suite := NewHandlerTestSuite(t) 1841 + defer suite.Cleanup() 1842 + 1843 + handler := CreateHandler(t, NewPublicationHandler) 1844 + ctx := context.Background() 1845 + 1846 + newNote := &models.Note{ 1847 + Title: "New Note", 1848 + Content: "# New content", 1849 + } 1850 + 1851 + rkey := "existing_rkey" 1852 + cid := "existing_cid" 1853 + publishedAt := time.Now().Add(-24 * time.Hour) 1854 + existingNote := &models.Note{ 1855 + Title: "Existing Note", 1856 + Content: "# Updated content", 1857 + LeafletRKey: &rkey, 1858 + LeafletCID: &cid, 1859 + PublishedAt: &publishedAt, 1860 + IsDraft: false, 1861 + } 1862 + 1863 + newID, err := handler.repos.Notes.Create(ctx, newNote) 1864 + suite.AssertNoError(err, "create new note") 1865 + 1866 + existingID, err := handler.repos.Notes.Create(ctx, existingNote) 1867 + suite.AssertNoError(err, "create existing note") 1868 + 1869 + session := &services.Session{ 1870 + DID: "did:plc:test123", 1871 + Handle: "test.bsky.social", 1872 + AccessJWT: "access_token", 1873 + RefreshJWT: "refresh_token", 1874 + PDSURL: "https://bsky.social", 1875 + ExpiresAt: time.Now().Add(2 * time.Hour), 1876 + Authenticated: true, 1877 + } 1878 + 1879 + err = handler.atproto.RestoreSession(session) 1880 + if err != nil { 1881 + t.Fatalf("Failed to restore session: %v", err) 1882 + } 1883 + 1884 + err = handler.Push(ctx, []int64{newID, existingID}, false) 1885 + 1886 + if err != nil && !strings.Contains(err.Error(), "not authenticated") { 1887 + t.Logf("Got error during push (expected for external service call): %v", err) 1888 + } 1889 + }) 1890 + 1891 + t.Run("continues processing after individual failures", func(t *testing.T) { 1892 + suite := NewHandlerTestSuite(t) 1893 + defer suite.Cleanup() 1894 + 1895 + handler := CreateHandler(t, NewPublicationHandler) 1896 + ctx := context.Background() 1897 + 1898 + note1 := &models.Note{ 1899 + Title: "Valid Note", 1900 + Content: "# Content", 1901 + } 1902 + 1903 + id1, err := handler.repos.Notes.Create(ctx, note1) 1904 + suite.AssertNoError(err, "create valid note") 1905 + 1906 + session := &services.Session{ 1907 + DID: "did:plc:test123", 1908 + Handle: "test.bsky.social", 1909 + AccessJWT: "access_token", 1910 + RefreshJWT: "refresh_token", 1911 + PDSURL: "https://bsky.social", 1912 + ExpiresAt: time.Now().Add(2 * time.Hour), 1913 + Authenticated: true, 1914 + } 1915 + 1916 + err = handler.atproto.RestoreSession(session) 1917 + if err != nil { 1918 + t.Fatalf("Failed to restore session: %v", err) 1919 + } 1920 + 1921 + invalidID := int64(999) 1922 + err = handler.Push(ctx, []int64{id1, invalidID}, false) 1923 + 1924 + if err == nil { 1925 + t.Error("Expected error due to invalid note ID") 1926 + } 1927 + 1928 + if err != nil && !strings.Contains(err.Error(), "error(s)") { 1929 + t.Errorf("Expected error message about failures, got '%v'", err) 1930 + } 1931 + }) 1932 + 1933 + t.Run("respects draft flag for new notes", func(t *testing.T) { 1934 + suite := NewHandlerTestSuite(t) 1935 + defer suite.Cleanup() 1936 + 1937 + handler := CreateHandler(t, NewPublicationHandler) 1938 + ctx := context.Background() 1939 + 1940 + note := &models.Note{ 1941 + Title: "Draft Note", 1942 + Content: "# Draft content", 1943 + } 1944 + 1945 + id, err := handler.repos.Notes.Create(ctx, note) 1946 + suite.AssertNoError(err, "create note") 1947 + 1948 + session := &services.Session{ 1949 + DID: "did:plc:test123", 1950 + Handle: "test.bsky.social", 1951 + AccessJWT: "access_token", 1952 + RefreshJWT: "refresh_token", 1953 + PDSURL: "https://bsky.social", 1954 + ExpiresAt: time.Now().Add(2 * time.Hour), 1955 + Authenticated: true, 1956 + } 1957 + 1958 + err = handler.atproto.RestoreSession(session) 1959 + if err != nil { 1960 + t.Fatalf("Failed to restore session: %v", err) 1961 + } 1962 + 1963 + err = handler.Push(ctx, []int64{id}, true) 1964 + 1965 + if err != nil && !strings.Contains(err.Error(), "not authenticated") { 1966 + t.Logf("Got error during push (expected for external service call): %v", err) 1967 + } 1968 + }) 1969 + }) 1655 1970 }
+1 -5
internal/public/convert.go
··· 45 45 46 46 // LocalImageResolver resolves local file paths to image metadata 47 47 type LocalImageResolver struct { 48 - // BlobUploader is called to upload image bytes and get a blob reference 49 - // If nil, creates a placeholder blob with a hash-based CID 50 - // 51 - // TODO: CLI commands that publish documents must provide this function to upload 52 - // images to AT Protocol blob storage via com.atproto.repo.uploadBlob 48 + // Called to upload image bytes and get a blob reference 53 49 BlobUploader func(data []byte, mimeType string) (Blob, error) 54 50 } 55 51
+30 -29
internal/services/atproto.go
··· 4 4 // - Pull: Fetch pub.leaflet.document records from AT Protocol repository 5 5 // - Post: Create new pub.leaflet.document records in AT Protocol repository 6 6 // - Push: Update existing pub.leaflet.document records in AT Protocol repository 7 - // 8 - // Publishing Workflow (TODO): 9 - // 1. Post - Create new document: 10 - // - Convert note to pub.leaflet.document format 11 - // - Upload any embedded images as blobs 12 - // - Create record with com.atproto.repo.createRecord 13 - // - Store returned rkey and cid in note metadata 14 - // 2. Push - Update existing document: 15 - // - Check if note has leaflet_rkey (indicates previously published) 16 - // - Convert updated note to pub.leaflet.document format 17 - // - Upload any new images as blobs 18 - // - Update record with com.atproto.repo.putRecord 19 - // - Update stored cid in note metadata 20 - // 3. Delete - Remove published document: 21 - // - Use com.atproto.repo.deleteRecord with stored rkey 22 - // - Clear leaflet metadata from note 23 - // 24 - // Blob Upload (TODO): 25 - // 1. Use com.atproto.repo.uploadBlob for images 26 - // 2. Returns blob reference with CID 27 - // 3. Include blob ref in ImageBlock structures 28 - // 29 - // Draft vs Published (TODO): 30 - // 1. Draft documents stored in collection: pub.leaflet.document.draft 31 - // 2. Published documents in: pub.leaflet.document 32 - // 3. Moving from draft to published requires: 33 - // - Delete from draft collection 34 - // - Create in published collection 35 - // - Update note metadata with new rkey 7 + // - Delete: Remove pub.leaflet.document records from AT Protocol repository 36 8 package services 37 9 38 10 import ( ··· 467 439 } 468 440 469 441 return nil 442 + } 443 + 444 + // UploadBlob uploads binary data as a blob to AT Protocol 445 + func (s *ATProtoService) UploadBlob(ctx context.Context, data []byte, mimeType string) (public.Blob, error) { 446 + if !s.IsAuthenticated() { 447 + return public.Blob{}, fmt.Errorf("not authenticated") 448 + } 449 + 450 + if len(data) == 0 { 451 + return public.Blob{}, fmt.Errorf("data cannot be empty") 452 + } 453 + 454 + if mimeType == "" { 455 + return public.Blob{}, fmt.Errorf("mimeType is required") 456 + } 457 + 458 + output, err := atproto.RepoUploadBlob(ctx, s.client, bytes.NewReader(data)) 459 + if err != nil { 460 + return public.Blob{}, fmt.Errorf("failed to upload blob: %w", err) 461 + } 462 + 463 + blob := public.Blob{ 464 + Type: public.TypeBlob, 465 + Ref: public.CID{Link: output.Blob.Ref.String()}, 466 + MimeType: output.Blob.MimeType, 467 + Size: int(output.Blob.Size), 468 + } 469 + 470 + return blob, nil 470 471 } 471 472 472 473 // Close cleans up resources
+3 -10
internal/ui/note_list_adapter.go
··· 44 44 } 45 45 46 46 func (n *NoteRecord) GetDescription() string { 47 - // Create a short description from tags and modification time 48 47 var parts []string 49 48 50 49 if len(n.Tags) > 0 { ··· 80 79 repoOpts.Archived = &archived 81 80 } 82 81 83 - // Apply search filter if provided 84 82 if opts.Search != "" { 85 - repoOpts.Content = opts.Search // Search in content 83 + repoOpts.Content = opts.Search 86 84 } 87 85 88 86 if opts.Limit > 0 { ··· 103 101 } 104 102 105 103 func (n *NoteDataSource) Count(ctx context.Context, opts ListOptions) (int, error) { 106 - // For simplicity, load all and count (could be optimized with a separate Count method) 107 104 items, err := n.Load(ctx, opts) 108 105 if err != nil { 109 106 return 0, err ··· 112 109 } 113 110 114 111 func (n *NoteDataSource) Search(ctx context.Context, query string, opts ListOptions) ([]ListItem, error) { 115 - // Set search in options and use regular Load 116 112 opts.Search = query 117 113 return n.Load(ctx, opts) 118 114 } ··· 123 119 opts.Title = "Notes" 124 120 } 125 121 126 - // Enable search functionality for notes 127 122 opts.ShowSearch = true 128 123 opts.Searchable = true 129 124 130 - // Set up view handler for markdown rendering 131 125 if opts.ViewHandler == nil { 132 126 opts.ViewHandler = func(item ListItem) string { 133 127 if noteRecord, ok := item.(*NoteRecord); ok { ··· 188 182 } 189 183 } 190 184 191 - // Render markdown 192 185 renderer, err := glamour.NewTermRenderer( 193 186 glamour.WithAutoStyle(), 194 187 glamour.WithWordWrap(80), 195 188 ) 196 189 if err != nil { 197 - return content.String() // Return unrendered if glamour fails 190 + return content.String() 198 191 } 199 192 200 193 rendered, err := renderer.Render(content.String()) 201 194 if err != nil { 202 - return content.String() // Return unrendered if rendering fails 195 + return content.String() 203 196 } 204 197 205 198 return rendered
+46 -22
internal/ui/note_list_adapter_test.go
··· 11 11 12 12 "github.com/stormlightlabs/noteleaf/internal/models" 13 13 "github.com/stormlightlabs/noteleaf/internal/repo" 14 + "github.com/stormlightlabs/noteleaf/internal/shared" 14 15 ) 15 16 16 17 type mockNoteRepository struct { ··· 42 43 } 43 44 } 44 45 45 - // Filter by content search 46 46 if options.Content != "" && !strings.Contains(note.Content, options.Content) { 47 47 continue 48 48 } ··· 57 57 return filtered, nil 58 58 } 59 59 60 + func (m *mockNoteRepository) ListPublished(ctx context.Context) ([]*models.Note, error) { 61 + if m.err != nil { 62 + return nil, m.err 63 + } 64 + var published []*models.Note 65 + for _, note := range m.notes { 66 + if note.LeafletRKey != nil && !note.IsDraft { 67 + published = append(published, note) 68 + } 69 + } 70 + return published, nil 71 + } 72 + 73 + func (m *mockNoteRepository) ListDrafts(ctx context.Context) ([]*models.Note, error) { 74 + if m.err != nil { 75 + return nil, m.err 76 + } 77 + var drafts []*models.Note 78 + for _, note := range m.notes { 79 + if note.LeafletRKey != nil && note.IsDraft { 80 + drafts = append(drafts, note) 81 + } 82 + } 83 + return drafts, nil 84 + } 85 + 86 + func (m *mockNoteRepository) GetLeafletNotes(ctx context.Context) ([]*models.Note, error) { 87 + if m.err != nil { 88 + return nil, m.err 89 + } 90 + var leafletNotes []*models.Note 91 + for _, note := range m.notes { 92 + if note.LeafletRKey != nil { 93 + leafletNotes = append(leafletNotes, note) 94 + } 95 + } 96 + return leafletNotes, nil 97 + } 98 + 60 99 func TestNoteAdapter(t *testing.T) { 61 100 t.Run("NoteRecord", func(t *testing.T) { 62 101 note := &models.Note{ ··· 88 127 for _, tt := range tests { 89 128 t.Run(tt.name, func(t *testing.T) { 90 129 result := record.GetField(tt.field) 91 - // For slices, do a deep comparison 92 130 if tags, ok := tt.expected.([]string); ok { 93 131 resultTags, ok := result.([]string) 94 132 if !ok || len(resultTags) != len(tags) { ··· 182 220 t.Errorf("Load() returned %d items, want 3", len(items)) 183 221 } 184 222 185 - // Check first item 186 223 if items[0].GetTitle() != "Work Note" { 187 224 t.Errorf("First item title = %q, want 'Work Note'", items[0].GetTitle()) 188 225 } ··· 323 360 } 324 361 325 362 outputStr := output.String() 326 - if !strings.Contains(outputStr, "Notes") { 327 - t.Error("Output should contain 'Notes' title") 328 - } 329 - if !strings.Contains(outputStr, "Test Note") { 330 - t.Error("Output should contain note title") 331 - } 363 + shared.AssertContains(t, outputStr, "Notes", "Output should contain 'Notes' title") 364 + shared.AssertContains(t, outputStr, "Test Note", "Output should contain note title") 332 365 }) 333 366 334 367 t.Run("Format Note for View", func(t *testing.T) { ··· 344 377 345 378 result := formatNoteForView(note) 346 379 347 - // Check that it contains expected elements 348 - if !strings.Contains(result, "Test Note") { 349 - t.Error("Formatted view should contain note title") 350 - } 351 - if !strings.Contains(result, "test") { 352 - t.Error("Formatted view should contain tags") 353 - } 354 - if !strings.Contains(result, "2023-01-01") { 355 - t.Error("Formatted view should contain created date") 356 - } 357 - if !strings.Contains(result, "2023-01-02") { 358 - t.Error("Formatted view should contain modified date") 359 - } 380 + shared.AssertContains(t, result, "Test Note", "Formatted view should contain note title") 381 + shared.AssertContains(t, result, "test", "Formatted view should contain tags") 382 + shared.AssertContains(t, result, "2023-01-01", "Formatted view should contain created date") 383 + shared.AssertContains(t, result, "2023-01-02", "Formatted view should contain modified date") 360 384 }) 361 385 }
+225
internal/ui/publication_list_adapter.go
··· 1 + package ui 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "strings" 8 + 9 + "github.com/charmbracelet/glamour" 10 + "github.com/stormlightlabs/noteleaf/internal/models" 11 + "github.com/stormlightlabs/noteleaf/internal/utils" 12 + ) 13 + 14 + // PublicationRecord adapts models.Note with leaflet metadata to work with DataList 15 + type PublicationRecord struct { 16 + *models.Note 17 + } 18 + 19 + func (p *PublicationRecord) GetField(name string) any { 20 + switch name { 21 + case "id": 22 + return p.ID 23 + case "title": 24 + return p.Title 25 + case "status": 26 + if p.IsDraft { 27 + return "draft" 28 + } 29 + return "published" 30 + case "published_at": 31 + return p.PublishedAt 32 + case "modified": 33 + return p.Modified 34 + case "leaflet_rkey": 35 + return p.LeafletRKey 36 + case "leaflet_cid": 37 + return p.LeafletCID 38 + default: 39 + return "" 40 + } 41 + } 42 + 43 + func (p *PublicationRecord) GetTitle() string { 44 + status := "draft" 45 + if !p.IsDraft { 46 + status = "published" 47 + } 48 + return fmt.Sprintf("[%d] %s (%s)", p.ID, p.Title, status) 49 + } 50 + 51 + func (p *PublicationRecord) GetDescription() string { 52 + var parts []string 53 + 54 + if p.PublishedAt != nil { 55 + parts = append(parts, "Published: "+p.PublishedAt.Format("2006-01-02 15:04")) 56 + } 57 + 58 + parts = append(parts, "Modified: "+p.Modified.Format("2006-01-02 15:04")) 59 + 60 + if p.LeafletRKey != nil { 61 + parts = append(parts, "rkey: "+*p.LeafletRKey) 62 + } 63 + 64 + return strings.Join(parts, " โ€ข ") 65 + } 66 + 67 + func (p *PublicationRecord) GetFilterValue() string { 68 + searchable := []string{p.Title, p.Content} 69 + if p.LeafletRKey != nil { 70 + searchable = append(searchable, *p.LeafletRKey) 71 + } 72 + return strings.Join(searchable, " ") 73 + } 74 + 75 + // PublicationDataSource loads notes with leaflet metadata 76 + type PublicationDataSource struct { 77 + repo utils.TestNoteRepository 78 + filter string // "all", "published", or "draft" 79 + } 80 + 81 + func (p *PublicationDataSource) Load(ctx context.Context, opts ListOptions) ([]ListItem, error) { 82 + var notes []*models.Note 83 + var err error 84 + 85 + switch p.filter { 86 + case "published": 87 + notes, err = p.repo.ListPublished(ctx) 88 + case "draft": 89 + notes, err = p.repo.ListDrafts(ctx) 90 + default: 91 + notes, err = p.repo.GetLeafletNotes(ctx) 92 + } 93 + 94 + if err != nil { 95 + return nil, err 96 + } 97 + 98 + if opts.Search != "" { 99 + var filtered []*models.Note 100 + searchLower := strings.ToLower(opts.Search) 101 + for _, note := range notes { 102 + if strings.Contains(strings.ToLower(note.Title), searchLower) || 103 + strings.Contains(strings.ToLower(note.Content), searchLower) || 104 + (note.LeafletRKey != nil && strings.Contains(strings.ToLower(*note.LeafletRKey), searchLower)) { 105 + filtered = append(filtered, note) 106 + } 107 + } 108 + notes = filtered 109 + } 110 + 111 + if opts.Limit > 0 && opts.Limit < len(notes) { 112 + notes = notes[:opts.Limit] 113 + } 114 + 115 + items := make([]ListItem, len(notes)) 116 + for i, note := range notes { 117 + items[i] = &PublicationRecord{Note: note} 118 + } 119 + 120 + return items, nil 121 + } 122 + 123 + func (p *PublicationDataSource) Count(ctx context.Context, opts ListOptions) (int, error) { 124 + items, err := p.Load(ctx, opts) 125 + if err != nil { 126 + return 0, err 127 + } 128 + return len(items), nil 129 + } 130 + 131 + func (p *PublicationDataSource) Search(ctx context.Context, query string, opts ListOptions) ([]ListItem, error) { 132 + opts.Search = query 133 + return p.Load(ctx, opts) 134 + } 135 + 136 + // NewPublicationDataList creates a new DataList for browsing published/draft documents 137 + func NewPublicationDataList(repo utils.TestNoteRepository, opts DataListOptions, filter string) *DataList { 138 + if opts.Title == "" { 139 + opts.Title = "Publications" 140 + } 141 + 142 + opts.ShowSearch = true 143 + opts.Searchable = true 144 + 145 + if opts.ViewHandler == nil { 146 + opts.ViewHandler = func(item ListItem) string { 147 + if pubRecord, ok := item.(*PublicationRecord); ok { 148 + return formatPublicationForView(pubRecord.Note) 149 + } 150 + return "Unable to display publication" 151 + } 152 + } 153 + 154 + source := &PublicationDataSource{ 155 + repo: repo, 156 + filter: filter, 157 + } 158 + 159 + return NewDataList(source, opts) 160 + } 161 + 162 + // NewPublicationListFromList creates a publication list using DataList 163 + func NewPublicationListFromList(repo utils.TestNoteRepository, output io.Writer, input io.Reader, static bool, filter string) *DataList { 164 + opts := DataListOptions{ 165 + Output: output, 166 + Input: input, 167 + Static: static, 168 + Title: "Publications", 169 + } 170 + return NewPublicationDataList(repo, opts, filter) 171 + } 172 + 173 + // formatPublicationForView formats a publication for display with glamour 174 + func formatPublicationForView(note *models.Note) string { 175 + var content strings.Builder 176 + 177 + content.WriteString("# " + note.Title + "\n\n") 178 + 179 + status := "published" 180 + if note.IsDraft { 181 + status = "draft" 182 + } 183 + content.WriteString("**Status:** " + status + "\n") 184 + 185 + if note.PublishedAt != nil { 186 + content.WriteString("**Published:** " + note.PublishedAt.Format("2006-01-02 15:04") + "\n") 187 + } 188 + 189 + content.WriteString("**Modified:** " + note.Modified.Format("2006-01-02 15:04") + "\n") 190 + 191 + if note.LeafletRKey != nil { 192 + content.WriteString("**RKey:** `" + *note.LeafletRKey + "`\n") 193 + } 194 + 195 + if note.LeafletCID != nil { 196 + content.WriteString("**CID:** `" + *note.LeafletCID + "`\n") 197 + } 198 + 199 + content.WriteString("\n---\n\n") 200 + 201 + noteContent := strings.TrimSpace(note.Content) 202 + if !strings.HasPrefix(noteContent, "# ") { 203 + content.WriteString(noteContent) 204 + } else { 205 + lines := strings.Split(noteContent, "\n") 206 + if len(lines) > 1 { 207 + content.WriteString(strings.Join(lines[1:], "\n")) 208 + } 209 + } 210 + 211 + renderer, err := glamour.NewTermRenderer( 212 + glamour.WithAutoStyle(), 213 + glamour.WithWordWrap(80), 214 + ) 215 + if err != nil { 216 + return content.String() 217 + } 218 + 219 + rendered, err := renderer.Render(content.String()) 220 + if err != nil { 221 + return content.String() 222 + } 223 + 224 + return rendered 225 + }
+599
internal/ui/publication_list_adapter_test.go
··· 1 + package ui 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "strings" 8 + "testing" 9 + "time" 10 + 11 + "github.com/stormlightlabs/noteleaf/internal/models" 12 + "github.com/stormlightlabs/noteleaf/internal/repo" 13 + ) 14 + 15 + type mockPublicationRepository struct { 16 + notes []*models.Note 17 + err error 18 + published []*models.Note 19 + drafts []*models.Note 20 + leafletAll []*models.Note 21 + } 22 + 23 + func (m *mockPublicationRepository) ListPublished(ctx context.Context) ([]*models.Note, error) { 24 + if m.err != nil { 25 + return nil, m.err 26 + } 27 + if m.published != nil { 28 + return m.published, nil 29 + } 30 + var published []*models.Note 31 + for _, note := range m.notes { 32 + if !note.IsDraft { 33 + published = append(published, note) 34 + } 35 + } 36 + return published, nil 37 + } 38 + 39 + func (m *mockPublicationRepository) ListDrafts(ctx context.Context) ([]*models.Note, error) { 40 + if m.err != nil { 41 + return nil, m.err 42 + } 43 + if m.drafts != nil { 44 + return m.drafts, nil 45 + } 46 + var drafts []*models.Note 47 + for _, note := range m.notes { 48 + if note.IsDraft { 49 + drafts = append(drafts, note) 50 + } 51 + } 52 + return drafts, nil 53 + } 54 + 55 + func (m *mockPublicationRepository) GetLeafletNotes(ctx context.Context) ([]*models.Note, error) { 56 + if m.err != nil { 57 + return nil, m.err 58 + } 59 + if m.leafletAll != nil { 60 + return m.leafletAll, nil 61 + } 62 + return m.notes, nil 63 + } 64 + 65 + func (m *mockPublicationRepository) List(ctx context.Context, options repo.NoteListOptions) ([]*models.Note, error) { 66 + if m.err != nil { 67 + return nil, m.err 68 + } 69 + return m.notes, nil 70 + } 71 + 72 + func TestPublicationAdapter(t *testing.T) { 73 + t.Run("PublicationRecord", func(t *testing.T) { 74 + rkey := "test-rkey-123" 75 + cid := "test-cid-456" 76 + publishedAt := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) 77 + 78 + note := &models.Note{ 79 + ID: 1, 80 + Title: "Test Publication", 81 + Content: "Publication content", 82 + Tags: []string{"article", "tech"}, 83 + IsDraft: false, 84 + PublishedAt: &publishedAt, 85 + Modified: time.Date(2024, 1, 16, 12, 0, 0, 0, time.UTC), 86 + LeafletRKey: &rkey, 87 + LeafletCID: &cid, 88 + } 89 + record := &PublicationRecord{Note: note} 90 + 91 + t.Run("GetField returns all publication fields", func(t *testing.T) { 92 + tests := []struct { 93 + field string 94 + expected any 95 + name string 96 + }{ 97 + {"id", int64(1), "id field"}, 98 + {"title", "Test Publication", "title field"}, 99 + {"status", "published", "status for published note"}, 100 + {"published_at", &publishedAt, "published_at field"}, 101 + {"modified", note.Modified, "modified field"}, 102 + {"leaflet_rkey", &rkey, "leaflet_rkey field"}, 103 + {"leaflet_cid", &cid, "leaflet_cid field"}, 104 + {"unknown", "", "unknown field returns empty string"}, 105 + } 106 + 107 + for _, tt := range tests { 108 + t.Run(tt.name, func(t *testing.T) { 109 + result := record.GetField(tt.field) 110 + if fmt.Sprintf("%v", result) != fmt.Sprintf("%v", tt.expected) { 111 + t.Errorf("GetField(%q) = %v, want %v", tt.field, result, tt.expected) 112 + } 113 + }) 114 + } 115 + }) 116 + 117 + t.Run("GetField returns draft status", func(t *testing.T) { 118 + draftNote := &models.Note{ 119 + ID: 2, 120 + Title: "Draft Note", 121 + IsDraft: true, 122 + } 123 + draftRecord := &PublicationRecord{Note: draftNote} 124 + 125 + status := draftRecord.GetField("status") 126 + if status != "draft" { 127 + t.Errorf("GetField(status) for draft = %v, want 'draft'", status) 128 + } 129 + }) 130 + 131 + t.Run("GetTitle formats with ID and status", func(t *testing.T) { 132 + title := record.GetTitle() 133 + if !strings.Contains(title, "[1]") { 134 + t.Errorf("GetTitle() should contain ID [1], got: %s", title) 135 + } 136 + if !strings.Contains(title, "Test Publication") { 137 + t.Errorf("GetTitle() should contain title, got: %s", title) 138 + } 139 + if !strings.Contains(title, "(published)") { 140 + t.Errorf("GetTitle() should contain status (published), got: %s", title) 141 + } 142 + }) 143 + 144 + t.Run("GetTitle shows draft status", func(t *testing.T) { 145 + draftNote := &models.Note{ 146 + ID: 3, 147 + Title: "Draft Article", 148 + IsDraft: true, 149 + } 150 + draftRecord := &PublicationRecord{Note: draftNote} 151 + 152 + title := draftRecord.GetTitle() 153 + if !strings.Contains(title, "(draft)") { 154 + t.Errorf("GetTitle() for draft should contain (draft), got: %s", title) 155 + } 156 + }) 157 + 158 + t.Run("GetDescription includes all metadata", func(t *testing.T) { 159 + description := record.GetDescription() 160 + 161 + if !strings.Contains(description, "Published: 2024-01-15 10:00") { 162 + t.Errorf("GetDescription() should contain published date, got: %s", description) 163 + } 164 + if !strings.Contains(description, "Modified: 2024-01-16 12:00") { 165 + t.Errorf("GetDescription() should contain modified date, got: %s", description) 166 + } 167 + if !strings.Contains(description, "rkey: test-rkey-123") { 168 + t.Errorf("GetDescription() should contain rkey, got: %s", description) 169 + } 170 + }) 171 + 172 + t.Run("GetDescription handles missing fields", func(t *testing.T) { 173 + minimalNote := &models.Note{ 174 + ID: 4, 175 + Title: "Minimal Note", 176 + Modified: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), 177 + } 178 + minimalRecord := &PublicationRecord{Note: minimalNote} 179 + 180 + description := minimalRecord.GetDescription() 181 + 182 + if strings.Contains(description, "Published:") { 183 + t.Errorf("GetDescription() should not contain Published for unpublished note, got: %s", description) 184 + } 185 + if strings.Contains(description, "rkey:") { 186 + t.Errorf("GetDescription() should not contain rkey when not set, got: %s", description) 187 + } 188 + if !strings.Contains(description, "Modified: 2024-01-01 00:00") { 189 + t.Errorf("GetDescription() should always contain Modified, got: %s", description) 190 + } 191 + }) 192 + 193 + t.Run("GetFilterValue includes searchable text", func(t *testing.T) { 194 + filterValue := record.GetFilterValue() 195 + 196 + if !strings.Contains(filterValue, "Test Publication") { 197 + t.Errorf("GetFilterValue() should contain title, got: %s", filterValue) 198 + } 199 + if !strings.Contains(filterValue, "Publication content") { 200 + t.Errorf("GetFilterValue() should contain content, got: %s", filterValue) 201 + } 202 + if !strings.Contains(filterValue, "test-rkey-123") { 203 + t.Errorf("GetFilterValue() should contain rkey, got: %s", filterValue) 204 + } 205 + }) 206 + 207 + t.Run("GetFilterValue handles missing rkey", func(t *testing.T) { 208 + noteWithoutRKey := &models.Note{ 209 + ID: 5, 210 + Title: "No RKey Note", 211 + Content: "Some content", 212 + } 213 + recordWithoutRKey := &PublicationRecord{Note: noteWithoutRKey} 214 + 215 + filterValue := recordWithoutRKey.GetFilterValue() 216 + 217 + if !strings.Contains(filterValue, "No RKey Note") { 218 + t.Errorf("GetFilterValue() should contain title, got: %s", filterValue) 219 + } 220 + if !strings.Contains(filterValue, "Some content") { 221 + t.Errorf("GetFilterValue() should contain content, got: %s", filterValue) 222 + } 223 + }) 224 + }) 225 + 226 + t.Run("PublicationDataSource", func(t *testing.T) { 227 + rkey1 := "rkey-published" 228 + rkey2 := "rkey-draft" 229 + publishedAt := time.Date(2024, 1, 10, 0, 0, 0, 0, time.UTC) 230 + 231 + notes := []*models.Note{ 232 + { 233 + ID: 1, 234 + Title: "Published Article", 235 + Content: "Published content", 236 + IsDraft: false, 237 + PublishedAt: &publishedAt, 238 + LeafletRKey: &rkey1, 239 + Modified: time.Now(), 240 + }, 241 + { 242 + ID: 2, 243 + Title: "Draft Article", 244 + Content: "Draft content", 245 + IsDraft: true, 246 + LeafletRKey: &rkey2, 247 + Modified: time.Now(), 248 + }, 249 + { 250 + ID: 3, 251 + Title: "Another Published", 252 + Content: "More published content", 253 + IsDraft: false, 254 + Modified: time.Now(), 255 + }, 256 + } 257 + 258 + t.Run("Load with all filter", func(t *testing.T) { 259 + mockRepo := &mockPublicationRepository{notes: notes} 260 + source := &PublicationDataSource{repo: mockRepo, filter: "all"} 261 + 262 + items, err := source.Load(context.Background(), ListOptions{}) 263 + if err != nil { 264 + t.Fatalf("Load() failed: %v", err) 265 + } 266 + 267 + if len(items) != 3 { 268 + t.Errorf("Load() with filter 'all' returned %d items, want 3", len(items)) 269 + } 270 + }) 271 + 272 + t.Run("Load with published filter", func(t *testing.T) { 273 + mockRepo := &mockPublicationRepository{notes: notes} 274 + source := &PublicationDataSource{repo: mockRepo, filter: "published"} 275 + 276 + items, err := source.Load(context.Background(), ListOptions{}) 277 + if err != nil { 278 + t.Fatalf("Load() failed: %v", err) 279 + } 280 + 281 + if len(items) != 2 { 282 + t.Errorf("Load() with filter 'published' returned %d items, want 2", len(items)) 283 + } 284 + 285 + for _, item := range items { 286 + pubRecord := item.(*PublicationRecord) 287 + if pubRecord.IsDraft { 288 + t.Error("Load() with 'published' filter should not return drafts") 289 + } 290 + } 291 + }) 292 + 293 + t.Run("Load with draft filter", func(t *testing.T) { 294 + mockRepo := &mockPublicationRepository{notes: notes} 295 + source := &PublicationDataSource{repo: mockRepo, filter: "draft"} 296 + 297 + items, err := source.Load(context.Background(), ListOptions{}) 298 + if err != nil { 299 + t.Fatalf("Load() failed: %v", err) 300 + } 301 + 302 + if len(items) != 1 { 303 + t.Errorf("Load() with filter 'draft' returned %d items, want 1", len(items)) 304 + } 305 + 306 + pubRecord := items[0].(*PublicationRecord) 307 + if !pubRecord.IsDraft { 308 + t.Error("Load() with 'draft' filter should only return drafts") 309 + } 310 + }) 311 + 312 + t.Run("Load with search query", func(t *testing.T) { 313 + mockRepo := &mockPublicationRepository{notes: notes} 314 + source := &PublicationDataSource{repo: mockRepo, filter: "all"} 315 + 316 + items, err := source.Load(context.Background(), ListOptions{Search: "Draft"}) 317 + if err != nil { 318 + t.Fatalf("Load() with search failed: %v", err) 319 + } 320 + 321 + if len(items) != 1 { 322 + t.Errorf("Load() with search 'Draft' returned %d items, want 1", len(items)) 323 + } 324 + 325 + if items[0].GetTitle() != "[2] Draft Article (draft)" { 326 + t.Errorf("Search result title = %q, want '[2] Draft Article (draft)'", items[0].GetTitle()) 327 + } 328 + }) 329 + 330 + t.Run("Load with search in content", func(t *testing.T) { 331 + mockRepo := &mockPublicationRepository{notes: notes} 332 + source := &PublicationDataSource{repo: mockRepo, filter: "all"} 333 + 334 + items, err := source.Load(context.Background(), ListOptions{Search: "Draft content"}) 335 + if err != nil { 336 + t.Fatalf("Load() with content search failed: %v", err) 337 + } 338 + 339 + if len(items) != 1 { 340 + t.Errorf("Load() searching content returned %d items, want 1", len(items)) 341 + } 342 + }) 343 + 344 + t.Run("Load with search in rkey", func(t *testing.T) { 345 + mockRepo := &mockPublicationRepository{notes: notes} 346 + source := &PublicationDataSource{repo: mockRepo, filter: "all"} 347 + 348 + items, err := source.Load(context.Background(), ListOptions{Search: "rkey-draft"}) 349 + if err != nil { 350 + t.Fatalf("Load() with rkey search failed: %v", err) 351 + } 352 + 353 + if len(items) != 1 { 354 + t.Errorf("Load() searching rkey returned %d items, want 1", len(items)) 355 + } 356 + 357 + pubRecord := items[0].(*PublicationRecord) 358 + if *pubRecord.LeafletRKey != "rkey-draft" { 359 + t.Errorf("Found note with rkey %q, want 'rkey-draft'", *pubRecord.LeafletRKey) 360 + } 361 + }) 362 + 363 + t.Run("Load with limit", func(t *testing.T) { 364 + mockRepo := &mockPublicationRepository{notes: notes} 365 + source := &PublicationDataSource{repo: mockRepo, filter: "all"} 366 + 367 + items, err := source.Load(context.Background(), ListOptions{Limit: 2}) 368 + if err != nil { 369 + t.Fatalf("Load() with limit failed: %v", err) 370 + } 371 + 372 + if len(items) != 2 { 373 + t.Errorf("Load() with limit 2 returned %d items, want 2", len(items)) 374 + } 375 + }) 376 + 377 + t.Run("Load error handling", func(t *testing.T) { 378 + testErr := fmt.Errorf("database error") 379 + mockRepo := &mockPublicationRepository{err: testErr} 380 + source := &PublicationDataSource{repo: mockRepo, filter: "all"} 381 + 382 + _, err := source.Load(context.Background(), ListOptions{}) 383 + if err != testErr { 384 + t.Errorf("Load() error = %v, want %v", err, testErr) 385 + } 386 + }) 387 + 388 + t.Run("Count", func(t *testing.T) { 389 + mockRepo := &mockPublicationRepository{notes: notes} 390 + source := &PublicationDataSource{repo: mockRepo, filter: "all"} 391 + 392 + count, err := source.Count(context.Background(), ListOptions{}) 393 + if err != nil { 394 + t.Fatalf("Count() failed: %v", err) 395 + } 396 + 397 + if count != 3 { 398 + t.Errorf("Count() = %d, want 3", count) 399 + } 400 + }) 401 + 402 + t.Run("Count with filter", func(t *testing.T) { 403 + mockRepo := &mockPublicationRepository{notes: notes} 404 + source := &PublicationDataSource{repo: mockRepo, filter: "draft"} 405 + 406 + count, err := source.Count(context.Background(), ListOptions{}) 407 + if err != nil { 408 + t.Fatalf("Count() with filter failed: %v", err) 409 + } 410 + 411 + if count != 1 { 412 + t.Errorf("Count() with draft filter = %d, want 1", count) 413 + } 414 + }) 415 + 416 + t.Run("Search", func(t *testing.T) { 417 + mockRepo := &mockPublicationRepository{notes: notes} 418 + source := &PublicationDataSource{repo: mockRepo, filter: "all"} 419 + 420 + items, err := source.Search(context.Background(), "Published", ListOptions{}) 421 + if err != nil { 422 + t.Fatalf("Search() failed: %v", err) 423 + } 424 + 425 + if len(items) != 2 { 426 + t.Errorf("Search() for 'Published' returned %d items, want 2", len(items)) 427 + } 428 + }) 429 + }) 430 + 431 + t.Run("NewPublicationDataList", func(t *testing.T) { 432 + notes := []*models.Note{ 433 + { 434 + ID: 1, 435 + Title: "Test Publication", 436 + Content: "Test content", 437 + IsDraft: false, 438 + Modified: time.Now(), 439 + }, 440 + } 441 + 442 + mockRepo := &mockPublicationRepository{notes: notes} 443 + 444 + opts := DataListOptions{ 445 + Output: &bytes.Buffer{}, 446 + Input: strings.NewReader("q\n"), 447 + Static: true, 448 + } 449 + 450 + list := NewPublicationDataList(mockRepo, opts, "all") 451 + if list == nil { 452 + t.Fatal("NewPublicationDataList() returned nil") 453 + } 454 + 455 + err := list.Browse(context.Background()) 456 + if err != nil { 457 + t.Errorf("Browse() failed: %v", err) 458 + } 459 + }) 460 + 461 + t.Run("NewPublicationListFromList", func(t *testing.T) { 462 + notes := []*models.Note{ 463 + { 464 + ID: 1, 465 + Title: "Test Publication", 466 + Content: "Test content", 467 + IsDraft: false, 468 + Modified: time.Now(), 469 + }, 470 + } 471 + 472 + mockRepo := &mockPublicationRepository{notes: notes} 473 + 474 + output := &bytes.Buffer{} 475 + input := strings.NewReader("q\n") 476 + 477 + list := NewPublicationListFromList(mockRepo, output, input, true, "all") 478 + if list == nil { 479 + t.Fatal("NewPublicationListFromList() returned nil") 480 + } 481 + 482 + err := list.Browse(context.Background()) 483 + if err != nil { 484 + t.Errorf("Browse() failed: %v", err) 485 + } 486 + 487 + outputStr := output.String() 488 + if !strings.Contains(outputStr, "Publications") { 489 + t.Error("Output should contain 'Publications' title") 490 + } 491 + if !strings.Contains(outputStr, "Test Publication") { 492 + t.Error("Output should contain publication title") 493 + } 494 + }) 495 + 496 + t.Run("formatPublicationForView", func(t *testing.T) { 497 + t.Run("formats published note with all metadata", func(t *testing.T) { 498 + rkey := "test-rkey" 499 + cid := "test-cid" 500 + publishedAt := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC) 501 + 502 + note := &models.Note{ 503 + ID: 1, 504 + Title: "Test Article", 505 + Content: "# Test Article\n\nThis is the article content.", 506 + IsDraft: false, 507 + PublishedAt: &publishedAt, 508 + Modified: time.Date(2024, 1, 16, 12, 0, 0, 0, time.UTC), 509 + LeafletRKey: &rkey, 510 + LeafletCID: &cid, 511 + } 512 + 513 + result := formatPublicationForView(note) 514 + 515 + if !strings.Contains(result, "Test Article") { 516 + t.Errorf("Formatted view should contain title\nGot: %s", result) 517 + } 518 + if !strings.Contains(result, "published") { 519 + t.Errorf("Formatted view should contain status 'published'\nGot: %s", result) 520 + } 521 + if !strings.Contains(result, "2024-01-15") { 522 + t.Errorf("Formatted view should contain published date\nGot: %s", result) 523 + } 524 + if !strings.Contains(result, "Modified") && !strings.Contains(result, "2024-01-16") { 525 + t.Errorf("Formatted view should contain modified date\nGot: %s", result) 526 + } 527 + if !strings.Contains(result, "test-rkey") { 528 + t.Error("Formatted view should contain rkey") 529 + } 530 + if !strings.Contains(result, "test-cid") { 531 + t.Error("Formatted view should contain cid") 532 + } 533 + }) 534 + 535 + t.Run("formats draft note", func(t *testing.T) { 536 + note := &models.Note{ 537 + ID: 2, 538 + Title: "Draft Article", 539 + Content: "Draft content here.", 540 + IsDraft: true, 541 + Modified: time.Date(2024, 1, 20, 14, 0, 0, 0, time.UTC), 542 + } 543 + 544 + result := formatPublicationForView(note) 545 + 546 + if !strings.Contains(result, "Draft Article") { 547 + t.Error("Formatted view should contain title") 548 + } 549 + if !strings.Contains(result, "draft") { 550 + t.Error("Formatted view should contain status 'draft'") 551 + } 552 + if strings.Contains(result, "Published:") { 553 + t.Error("Formatted draft view should not contain published date") 554 + } 555 + if !strings.Contains(result, "2024-01-20 14:00") { 556 + t.Error("Formatted view should contain modified date") 557 + } 558 + }) 559 + 560 + t.Run("handles content without title header", func(t *testing.T) { 561 + note := &models.Note{ 562 + ID: 3, 563 + Title: "Plain Content", 564 + Content: "This content has no markdown header.", 565 + IsDraft: false, 566 + Modified: time.Now(), 567 + } 568 + 569 + result := formatPublicationForView(note) 570 + 571 + if !strings.Contains(result, "Plain Content") { 572 + t.Error("Formatted view should contain title") 573 + } 574 + if !strings.Contains(result, "This content has no markdown header") { 575 + t.Error("Formatted view should contain full content") 576 + } 577 + }) 578 + 579 + t.Run("strips duplicate title from content", func(t *testing.T) { 580 + note := &models.Note{ 581 + ID: 4, 582 + Title: "Article Title", 583 + Content: "# Article Title\n\nContent after title.", 584 + IsDraft: false, 585 + Modified: time.Now(), 586 + } 587 + 588 + result := formatPublicationForView(note) 589 + 590 + titleCount := strings.Count(result, "Article Title") 591 + if titleCount < 1 { 592 + t.Error("Formatted view should contain title at least once") 593 + } 594 + if !strings.Contains(result, "Content after title") { 595 + t.Error("Formatted view should contain content after title") 596 + } 597 + }) 598 + }) 599 + }
+4 -2
internal/utils/utils.go
··· 32 32 logger.SetLevel(log.InfoLevel) 33 33 } 34 34 35 - // Set format 36 35 if format == "json" { 37 36 logger.SetFormatter(log.JSONFormatter) 38 37 } else { ··· 66 65 List(ctx context.Context, options repo.BookListOptions) ([]*models.Book, error) 67 66 } 68 67 69 - // TestNoteRepository interface for dependency injection in tests 68 + // TestNoteRepository interface for dependency injection in tests. 70 69 type TestNoteRepository interface { 71 70 List(ctx context.Context, options repo.NoteListOptions) ([]*models.Note, error) 71 + ListPublished(ctx context.Context) ([]*models.Note, error) 72 + ListDrafts(ctx context.Context) ([]*models.Note, error) 73 + GetLeafletNotes(ctx context.Context) ([]*models.Note, error) 72 74 }
+1 -1
justfile
··· 6 6 7 7 # Run all tests 8 8 test: 9 - go test ./... -v 9 + go test ./... 10 10 11 11 # Run tests with coverage 12 12 coverage: