cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
1package handlers
2
3import (
4 "context"
5 "fmt"
6 "net/http"
7 "os"
8 "path/filepath"
9 "strings"
10 "time"
11
12 "github.com/stormlightlabs/noteleaf/internal/articles"
13 "github.com/stormlightlabs/noteleaf/internal/models"
14 "github.com/stormlightlabs/noteleaf/internal/repo"
15 "github.com/stormlightlabs/noteleaf/internal/store"
16 "github.com/stormlightlabs/noteleaf/internal/ui"
17)
18
19const (
20 articleUserAgent = "curl/8.4.0"
21 articleAcceptHeader = "*/*"
22 articleLangHeader = "en-US,en;q=0.8"
23)
24
25type headerRoundTripper struct {
26 rt http.RoundTripper
27}
28
29// ArticleHandler handles all article-related commands
30type ArticleHandler struct {
31 db *store.Database
32 config *store.Config
33 repos *repo.Repositories
34 parser articles.Parser
35}
36
37// NewArticleHandler creates a new article handler
38func NewArticleHandler() (*ArticleHandler, error) {
39 db, err := store.NewDatabase()
40 if err != nil {
41 return nil, fmt.Errorf("failed to initialize database: %w", err)
42 }
43
44 config, err := store.LoadConfig()
45 if err != nil {
46 return nil, fmt.Errorf("failed to load configuration: %w", err)
47 }
48
49 repos := repo.NewRepositories(db.DB)
50 parser, err := articles.NewArticleParser(newArticleHTTPClient())
51 if err != nil {
52 return nil, fmt.Errorf("failed to initialize article parser: %w", err)
53 }
54
55 return &ArticleHandler{
56 db: db,
57 config: config,
58 repos: repos,
59 parser: parser,
60 }, nil
61}
62
63func newArticleHTTPClient() *http.Client {
64 baseTransport := http.DefaultTransport
65
66 if transport, ok := http.DefaultTransport.(*http.Transport); ok {
67 baseTransport = transport.Clone()
68 }
69
70 return &http.Client{
71 Timeout: 30 * time.Second,
72 Transport: &headerRoundTripper{rt: baseTransport},
73 }
74}
75
76func (h *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
77 if h.rt == nil {
78 h.rt = http.DefaultTransport
79 }
80
81 clone := req.Clone(req.Context())
82 clone.Header = req.Header.Clone()
83
84 if clone.Header.Get("User-Agent") == "" {
85 clone.Header.Set("User-Agent", articleUserAgent)
86 }
87
88 if clone.Header.Get("Accept") == "" {
89 clone.Header.Set("Accept", articleAcceptHeader)
90 }
91
92 if clone.Header.Get("Accept-Language") == "" {
93 clone.Header.Set("Accept-Language", articleLangHeader)
94 }
95
96 if clone.Header.Get("Connection") == "" {
97 clone.Header.Set("Connection", "keep-alive")
98 }
99
100 return h.rt.RoundTrip(clone)
101}
102
103// Close cleans up resources
104func (h *ArticleHandler) Close() error {
105 if h.db != nil {
106 return h.db.Close()
107 }
108 return nil
109}
110
111// Add handles adding an article from a URL
112func (h *ArticleHandler) Add(ctx context.Context, url string) error {
113 existing, err := h.repos.Articles.GetByURL(ctx, url)
114 if err == nil {
115 ui.Warningln("Article already exists: %s (ID: %d)", ui.TableTitleStyle.Render(existing.Title), existing.ID)
116 return nil
117 }
118
119 ui.Infoln("Parsing article from: %s", url)
120
121 dir, err := h.getStorageDirectory()
122 if err != nil {
123 return fmt.Errorf("failed to get article storage dir %w", err)
124 }
125
126 content, err := h.parser.ParseURL(url)
127 if err != nil {
128 return fmt.Errorf("failed to parse article: %w", err)
129 }
130
131 mdPath, htmlPath, err := h.parser.SaveArticle(content, dir)
132 if err != nil {
133 return fmt.Errorf("failed to save article: %w", err)
134 }
135
136 article := &models.Article{
137 URL: url,
138 Title: content.Title,
139 Author: content.Author,
140 Date: content.Date,
141 MarkdownPath: mdPath,
142 HTMLPath: htmlPath,
143 Created: time.Now(),
144 Modified: time.Now(),
145 }
146
147 id, err := h.repos.Articles.Create(ctx, article)
148 if err != nil {
149 os.Remove(article.MarkdownPath)
150 os.Remove(article.HTMLPath)
151 return fmt.Errorf("failed to save article to database: %w", err)
152 }
153
154 ui.Infoln("Article saved successfully!")
155 ui.Infoln("ID: %d", id)
156 ui.Infoln("Title: %s", ui.TableTitleStyle.Render(article.Title))
157 if article.Author != "" {
158 ui.Infoln("Author: %s", ui.TableHeaderStyle.Render(article.Author))
159 }
160 if article.Date != "" {
161 ui.Infoln("Date: %s", article.Date)
162 }
163 ui.Infoln("Markdown: %s", article.MarkdownPath)
164 ui.Infoln("HTML: %s", article.HTMLPath)
165
166 return nil
167}
168
169// List handles listing articles with optional filtering
170func (h *ArticleHandler) List(ctx context.Context, query string, author string, limit int) error {
171 opts := &repo.ArticleListOptions{
172 Title: query,
173 Author: author,
174 Limit: limit,
175 }
176
177 articles, err := h.repos.Articles.List(ctx, opts)
178 if err != nil {
179 return fmt.Errorf("failed to list articles: %w", err)
180 }
181
182 if len(articles) == 0 {
183 ui.Warningln("No articles found.")
184 return nil
185 }
186
187 ui.Infoln("Found %d article(s):\n", len(articles))
188 for _, article := range articles {
189 ui.Infoln("ID: %d", article.ID)
190 ui.Infoln("Title: %s", ui.TableTitleStyle.Render(article.Title))
191 if article.Author != "" {
192 ui.Infoln("Author: %s", ui.TableHeaderStyle.Render(article.Author))
193 }
194 if article.Date != "" {
195 ui.Infoln("Date: %s", article.Date)
196 }
197 ui.Infoln("URL: %s", article.URL)
198 ui.Infoln("Added: %s", article.Created.Format("2006-01-02 15:04:05"))
199 ui.Plainln("---")
200 }
201 return nil
202}
203
204// View handles viewing an article by ID
205func (h *ArticleHandler) View(ctx context.Context, id int64) error {
206 article, err := h.repos.Articles.Get(ctx, id)
207 if err != nil {
208 return fmt.Errorf("failed to get article: %w", err)
209 }
210
211 ui.Infoln("Title: %s", ui.TableTitleStyle.Render(article.Title))
212 if article.Author != "" {
213 ui.Infoln("Author: %s", ui.TableHeaderStyle.Render(article.Author))
214 }
215 if article.Date != "" {
216 ui.Infoln("Date: %s", article.Date)
217 }
218 ui.Infoln("URL: %s", article.URL)
219 ui.Infoln("Added: %s", article.Created.Format("2006-01-02 15:04:05"))
220 ui.Infoln("Modified: %s", article.Modified.Format("2006-01-02 15:04:05"))
221 ui.Newline()
222
223 ui.Info("Markdown file: %s", article.MarkdownPath)
224 if _, err := os.Stat(article.MarkdownPath); os.IsNotExist(err) {
225 ui.Warning(" (file not found)")
226 }
227
228 ui.Newline()
229
230 ui.Info("HTML file: %s", article.HTMLPath)
231 if _, err := os.Stat(article.HTMLPath); os.IsNotExist(err) {
232 ui.Warning(" (file not found)")
233 }
234 ui.Newline()
235
236 if _, err := os.Stat(article.MarkdownPath); err == nil {
237 ui.Headerln("--- Content Preview ---")
238 content, err := os.ReadFile(article.MarkdownPath)
239 if err == nil {
240 lines := strings.Split(string(content), "\n")
241 previewLines := min(len(lines), 20)
242
243 for i := range previewLines {
244 ui.Plainln("%v", lines[i])
245 }
246
247 if len(lines) > previewLines {
248 ui.Plainln("\n... (%d more lines)", len(lines)-previewLines)
249 ui.Plainln("Read full content: %s", article.MarkdownPath)
250 }
251 }
252 }
253
254 return nil
255}
256
257// Remove handles removing an article by ID
258func (h *ArticleHandler) Remove(ctx context.Context, id int64) error {
259 article, err := h.repos.Articles.Get(ctx, id)
260 if err != nil {
261 return fmt.Errorf("failed to get article: %w", err)
262 }
263
264 err = h.repos.Articles.Delete(ctx, id)
265 if err != nil {
266 return fmt.Errorf("failed to remove article from database: %w", err)
267 }
268
269 if _, err := os.Stat(article.MarkdownPath); err == nil {
270 if rmErr := os.Remove(article.MarkdownPath); rmErr != nil {
271 ui.Warningln("Warning: failed to remove markdown file: %v", rmErr)
272 }
273 }
274
275 if _, err := os.Stat(article.HTMLPath); err == nil {
276 if rmErr := os.Remove(article.HTMLPath); rmErr != nil {
277 ui.Warningln("Warning: failed to remove HTML file: %v", rmErr)
278 }
279 }
280
281 ui.Titleln("Article removed: %s (ID: %d)", article.Title, id)
282 return nil
283}
284
285// Help shows supported domains (to complement default cobra/fang help)
286func (h *ArticleHandler) Help() error {
287 domains := h.parser.GetSupportedDomains()
288
289 ui.Newline()
290
291 if len(domains) > 0 {
292 ui.Headerln("Supported sites (%d):", len(domains))
293 for _, domain := range domains {
294 ui.Plainln(" - %s", domain)
295 }
296 } else {
297 ui.Plainln("No parsing rules loaded.")
298 }
299
300 ui.Newline()
301 dir, err := h.getStorageDirectory()
302 if err != nil {
303 return fmt.Errorf("failed to get storage directory: %w", err)
304 }
305 ui.Headerln("%s %s", ui.TableHeaderStyle.Render("Storage directory:"), dir)
306
307 return nil
308}
309
310// Read displays an article's content with formatted markdown rendering
311func (h *ArticleHandler) Read(ctx context.Context, id int64) error {
312 article, err := h.repos.Articles.Get(ctx, id)
313 if err != nil {
314 return fmt.Errorf("failed to get article: %w", err)
315 }
316
317 if _, err := os.Stat(article.MarkdownPath); os.IsNotExist(err) {
318 return fmt.Errorf("markdown file not found: %s", article.MarkdownPath)
319 }
320
321 content, err := os.ReadFile(article.MarkdownPath)
322 if err != nil {
323 return fmt.Errorf("failed to read markdown file: %w", err)
324 }
325
326 if rendered, err := renderMarkdown(string(content)); err != nil {
327 return err
328 } else {
329 fmt.Print(rendered)
330 return nil
331 }
332
333}
334
335func (h *ArticleHandler) getStorageDirectory() (string, error) {
336 if h.config.ArticlesDir != "" {
337 return h.config.ArticlesDir, nil
338 }
339
340 dataDir, err := store.GetDataDir()
341 if err != nil {
342 return "", err
343 }
344 return filepath.Join(dataDir, "articles"), nil
345}