cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
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}