cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 🍃
charm
leaflet
readability
golang
1package ui
2
3import (
4 "context"
5 "fmt"
6 "io"
7 "strings"
8
9 "github.com/stormlightlabs/noteleaf/internal/models"
10 "github.com/stormlightlabs/noteleaf/internal/repo"
11 "github.com/stormlightlabs/noteleaf/internal/utils"
12)
13
14// BookRecord adapts models.Book to work with DataList
15type BookRecord struct {
16 *models.Book
17}
18
19func (b *BookRecord) GetField(name string) any {
20 switch name {
21 case "id":
22 return b.ID
23 case "title":
24 return b.Title
25 case "author":
26 return b.Author
27 case "status":
28 return b.Status
29 case "progress":
30 return b.Progress
31 case "pages":
32 return b.Pages
33 case "rating":
34 return b.Rating
35 case "notes":
36 return b.Notes
37 case "added":
38 return b.Added
39 case "started":
40 return b.Started
41 case "finished":
42 return b.Finished
43 default:
44 return ""
45 }
46}
47
48func (b *BookRecord) GetTitle() string {
49 return b.Title
50}
51
52func (b *BookRecord) GetDescription() string {
53 var parts []string
54
55 if b.Author != "" {
56 parts = append(parts, "by "+b.Author)
57 }
58
59 if b.Status != "" {
60 parts = append(parts, utils.Titlecase(b.Status))
61 }
62
63 if b.Pages > 0 {
64 parts = append(parts, fmt.Sprintf("%d pages", b.Pages))
65 }
66
67 if b.Progress > 0 && b.Progress < 100 {
68 parts = append(parts, fmt.Sprintf("%d%%", b.Progress))
69 }
70
71 return strings.Join(parts, " • ")
72}
73
74func (b *BookRecord) GetFilterValue() string {
75 // Make books searchable by title, author, and notes
76 searchable := []string{b.Title, b.Author, b.Notes}
77 return strings.Join(searchable, " ")
78}
79
80// BookDataSource adapts BookRepository to work with DataList
81type BookDataSource struct {
82 repo utils.TestBookRepository
83 status string
84}
85
86func (b *BookDataSource) Load(ctx context.Context, opts ListOptions) ([]ListItem, error) {
87 repoOpts := repo.BookListOptions{}
88
89 if b.status != "" {
90 repoOpts.Status = b.status
91 }
92
93 if opts.Search != "" {
94 // Simple search in title/author (could be enhanced)
95 repoOpts.Search = opts.Search
96 }
97
98 if opts.Limit > 0 {
99 repoOpts.Limit = opts.Limit
100 }
101
102 books, err := b.repo.List(ctx, repoOpts)
103 if err != nil {
104 return nil, err
105 }
106
107 items := make([]ListItem, len(books))
108 for i, book := range books {
109 items[i] = &BookRecord{Book: book}
110 }
111
112 return items, nil
113}
114
115func (b *BookDataSource) Count(ctx context.Context, opts ListOptions) (int, error) {
116 // For simplicity, load all and count (could be optimized)
117 items, err := b.Load(ctx, opts)
118 if err != nil {
119 return 0, err
120 }
121 return len(items), nil
122}
123
124func (b *BookDataSource) Search(ctx context.Context, query string, opts ListOptions) ([]ListItem, error) {
125 // Set search in options and use regular Load
126 opts.Search = query
127 return b.Load(ctx, opts)
128}
129
130// formatBookForView formats a book for detailed viewing
131func formatBookForView(book *models.Book) string {
132 var content strings.Builder
133
134 content.WriteString(fmt.Sprintf("# %s\n\n", book.Title))
135
136 if book.Author != "" {
137 content.WriteString(fmt.Sprintf("**Author:** %s\n", book.Author))
138 }
139
140 if book.Status != "" {
141 content.WriteString(fmt.Sprintf("**Status:** %s\n", utils.Titlecase(book.Status)))
142 }
143
144 if book.Progress > 0 {
145 content.WriteString(fmt.Sprintf("**Progress:** %d%%\n", book.Progress))
146 }
147
148 if book.Pages > 0 {
149 content.WriteString(fmt.Sprintf("**Pages:** %d\n", book.Pages))
150 }
151
152 if book.Rating > 0 {
153 content.WriteString(fmt.Sprintf("**Rating:** %.1f/5\n", book.Rating))
154 }
155
156 content.WriteString(fmt.Sprintf("**Added:** %s\n", book.Added.Format("2006-01-02 15:04")))
157
158 if book.Started != nil {
159 content.WriteString(fmt.Sprintf("**Started:** %s\n", book.Started.Format("2006-01-02 15:04")))
160 }
161
162 if book.Finished != nil {
163 content.WriteString(fmt.Sprintf("**Finished:** %s\n", book.Finished.Format("2006-01-02 15:04")))
164 }
165
166 if book.Notes != "" {
167 content.WriteString(fmt.Sprintf("\n**Notes:**\n%s\n", book.Notes))
168 }
169
170 return content.String()
171}
172
173// NewBookDataList creates a new DataList for browsing books
174func NewBookDataList(repo utils.TestBookRepository, opts DataListOptions, status string) *DataList {
175 if opts.Title == "" {
176 opts.Title = "Books"
177 }
178
179 // Enable search functionality
180 opts.ShowSearch = true
181 opts.Searchable = true
182
183 // Set up view handler for book details
184 if opts.ViewHandler == nil {
185 opts.ViewHandler = func(item ListItem) string {
186 if bookRecord, ok := item.(*BookRecord); ok {
187 return formatBookForView(bookRecord.Book)
188 }
189 return "Unable to display book"
190 }
191 }
192
193 source := &BookDataSource{
194 repo: repo,
195 status: status,
196 }
197
198 return NewDataList(source, opts)
199}
200
201// NewBookListFromList creates a BookList-compatible interface using DataList
202func NewBookListFromList(repo utils.TestBookRepository, output io.Writer, input io.Reader, static bool, status string) *DataList {
203 opts := DataListOptions{
204 Output: output,
205 Input: input,
206 Static: static,
207 Title: "Books",
208 }
209 return NewBookDataList(repo, opts, status)
210}