cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
29
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 345 lines 8.9 kB view raw
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}