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/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
15type PublicationRecord struct {
16 *models.Note
17}
18
19func (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
43func (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
51func (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
67func (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
76type PublicationDataSource struct {
77 repo utils.TestNoteRepository
78 filter string // "all", "published", or "draft"
79}
80
81func (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
123func (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
131func (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
137func 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
163func 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// buildPublicationMarkdown builds markdown content for a publication without rendering
174func buildPublicationMarkdown(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:** `" + ObfuscateMiddle(*note.LeafletRKey, 3, 3) + "`\n")
193 }
194
195 if note.LeafletCID != nil {
196 content.WriteString("- **CID:** `" + ObfuscateMiddle(*note.LeafletCID, 3, 3) + "`\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 return content.String()
212}
213
214// formatPublicationForView formats a publication for display with glamour
215func formatPublicationForView(note *models.Note) string {
216 markdown := buildPublicationMarkdown(note)
217
218 renderer, err := glamour.NewTermRenderer(
219 glamour.WithStandardStyle("tokyo-night"),
220 glamour.WithWordWrap(80),
221 )
222 if err != nil {
223 return markdown
224 }
225
226 rendered, err := renderer.Render(markdown)
227 if err != nil {
228 return markdown
229 }
230
231 return rendered
232}