cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat: cover more xpath rules

* refactor (wip) color palettes with wrappers

+447 -230
+4
codecov.yml
··· 23 23 - "internal/**/test_utilities.go" 24 24 - "internal/handlers/handler_test_suite.go" 25 25 - "internal/tools/docgen.go" 26 + - "internal/ui/common.go" 27 + - "internal/ui/palette.go" 28 + - "internal/ui/logo.go" 29 +
+4 -4
go.mod
··· 59 59 github.com/muesli/reflow v0.3.0 // indirect 60 60 github.com/yuin/goldmark v1.7.8 // indirect 61 61 github.com/yuin/goldmark-emoji v1.0.5 // indirect 62 - golang.org/x/net v0.44.0 // indirect 62 + golang.org/x/net v0.46.0 63 63 golang.org/x/sync v0.17.0 // indirect 64 - golang.org/x/term v0.35.0 // indirect 64 + golang.org/x/term v0.36.0 // indirect 65 65 ) 66 66 67 67 require ( ··· 91 91 github.com/spf13/pflag v1.0.6 // indirect 92 92 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 93 93 golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect 94 - golang.org/x/sys v0.36.0 // indirect 95 - golang.org/x/text v0.29.0 94 + golang.org/x/sys v0.37.0 // indirect 95 + golang.org/x/text v0.30.0 96 96 )
+8 -8
go.sum
··· 177 177 golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 178 178 golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 179 179 golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 180 - golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= 181 - golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= 180 + golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= 181 + golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= 182 182 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 183 183 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 184 184 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 202 202 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 203 203 golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 204 204 golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 205 - golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= 206 - golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 205 + golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= 206 + golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 207 207 golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 208 208 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 209 209 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= ··· 214 214 golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 215 215 golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 216 216 golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 217 - golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= 218 - golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= 217 + golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= 218 + golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= 219 219 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 220 220 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 221 221 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= ··· 226 226 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 227 227 golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 228 228 golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 229 - golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= 230 - golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= 229 + golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= 230 + golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= 231 231 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= 232 232 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 233 233 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+156 -24
internal/articles/parser.go
··· 18 18 "github.com/gomarkdown/markdown/html" 19 19 "github.com/gomarkdown/markdown/parser" 20 20 "github.com/stormlightlabs/noteleaf/internal/models" 21 + exhtml "golang.org/x/net/html" 21 22 ) 22 23 23 24 //go:embed rules/*.txt ··· 34 35 35 36 // ParsingRule represents XPath rules for extracting content from a specific domain 36 37 type ParsingRule struct { 37 - Domain string 38 - Title string 39 - Author string 40 - Date string 41 - Body string 42 - Strip []string // XPath selectors for elements to remove 43 - TestURLs []string 38 + Domain string 39 + Title string 40 + Author string 41 + Date string 42 + Body string 43 + Strip []string // XPath selectors for elements to remove 44 + StripIDsOrClasses []string 45 + TestURLs []string 46 + Headers map[string]string 47 + Prune bool 48 + Tidy bool 44 49 } 45 50 46 51 // Parser interface defines methods for parsing articles from URLs ··· 138 143 rule.Body = value 139 144 case "strip": 140 145 rule.Strip = append(rule.Strip, value) 146 + case "strip_id_or_class": 147 + rule.StripIDsOrClasses = append(rule.StripIDsOrClasses, value) 148 + case "prune": 149 + rule.Prune = parseBool(value) 150 + case "tidy": 151 + rule.Tidy = parseBool(value) 141 152 case "test_url": 142 153 rule.TestURLs = append(rule.TestURLs, value) 154 + default: 155 + if strings.HasPrefix(key, "http_header(") && strings.HasSuffix(key, ")") { 156 + headerName := strings.TrimSuffix(strings.TrimPrefix(key, "http_header("), ")") 157 + if headerName != "" { 158 + if rule.Headers == nil { 159 + rule.Headers = make(map[string]string) 160 + } 161 + rule.Headers[http.CanonicalHeaderKey(headerName)] = value 162 + } 163 + } 143 164 } 144 165 } 145 166 ··· 150 171 return rule, nil 151 172 } 152 173 174 + func parseBool(value string) bool { 175 + switch strings.ToLower(strings.TrimSpace(value)) { 176 + case "1", "true", "yes", "on": 177 + return true 178 + default: 179 + return false 180 + } 181 + } 182 + 183 + func (p *ArticleParser) findRule(domain string) *ParsingRule { 184 + for ruleDomain, rule := range p.rules { 185 + if domain == ruleDomain || strings.HasSuffix(domain, ruleDomain) { 186 + return rule 187 + } 188 + } 189 + return nil 190 + } 191 + 153 192 // ParseURL extracts article content from a given URL 154 193 func (p *ArticleParser) ParseURL(s string) (*ParsedContent, error) { 155 194 parsedURL, err := url.Parse(s) ··· 159 198 160 199 domain := parsedURL.Hostname() 161 200 162 - resp, err := p.client.Get(s) 201 + rule := p.findRule(domain) 202 + 203 + req, err := http.NewRequest(http.MethodGet, s, nil) 204 + if err != nil { 205 + return nil, fmt.Errorf("failed to create request: %w", err) 206 + } 207 + 208 + if rule != nil { 209 + for header, value := range rule.Headers { 210 + if value == "" { 211 + continue 212 + } 213 + if req.Header.Get(header) == "" { 214 + req.Header.Set(header, value) 215 + } 216 + } 217 + } 218 + 219 + resp, err := p.client.Do(req) 163 220 if err != nil { 164 221 return nil, fmt.Errorf("failed to fetch URL: %w", err) 165 222 } ··· 179 236 180 237 // ParseHTML extracts article content from HTML string using domain-specific rules 181 238 func (p *ArticleParser) Parse(htmlContent, domain, sourceURL string) (*ParsedContent, error) { 182 - var rule *ParsingRule 183 - for ruleDomain, r := range p.rules { 184 - if strings.Contains(domain, ruleDomain) { 185 - rule = r 186 - break 187 - } 188 - } 239 + rule := p.findRule(domain) 189 240 190 241 if rule == nil { 191 242 return nil, fmt.Errorf("no parsing rule found for domain: %s", domain) ··· 217 268 } 218 269 219 270 if rule.Body != "" { 220 - if bodyNode := htmlquery.FindOne(doc, rule.Body); bodyNode != nil { 221 - for _, stripXPath := range rule.Strip { 222 - stripNodes := htmlquery.Find(bodyNode, stripXPath) 223 - for _, node := range stripNodes { 224 - node.Parent.RemoveChild(node) 225 - } 226 - } 271 + bodyNode := htmlquery.FindOne(doc, rule.Body) 272 + if bodyNode == nil { 273 + return nil, fmt.Errorf("could not extract body content from HTML") 274 + } 227 275 228 - content.Content = strings.TrimSpace(htmlquery.InnerText(bodyNode)) 276 + for _, stripXPath := range rule.Strip { 277 + removeNodesByXPath(bodyNode, stripXPath) 229 278 } 279 + 280 + for _, identifier := range rule.StripIDsOrClasses { 281 + removeNodesByIdentifier(bodyNode, identifier) 282 + } 283 + 284 + removeDefaultNonContentNodes(bodyNode) 285 + 286 + content.Content = normalizeWhitespace(htmlquery.InnerText(bodyNode)) 230 287 } 231 288 232 289 if content.Title == "" { ··· 236 293 return content, nil 237 294 } 238 295 296 + func removeNodesByXPath(root *exhtml.Node, xpath string) { 297 + if root == nil { 298 + return 299 + } 300 + 301 + xpath = strings.TrimSpace(xpath) 302 + if xpath == "" { 303 + return 304 + } 305 + 306 + nodes := htmlquery.Find(root, xpath) 307 + for _, node := range nodes { 308 + if node != nil && node.Parent != nil { 309 + node.Parent.RemoveChild(node) 310 + } 311 + } 312 + } 313 + 314 + func removeNodesByIdentifier(root *exhtml.Node, identifier string) { 315 + identifier = strings.TrimSpace(identifier) 316 + if root == nil || identifier == "" { 317 + return 318 + } 319 + 320 + idLiteral := buildXPathLiteral(identifier) 321 + removeNodesByXPath(root, fmt.Sprintf(".//*[@id=%s]", idLiteral)) 322 + 323 + classLiteral := buildXPathLiteral(" " + identifier + " ") 324 + removeNodesByXPath(root, fmt.Sprintf(".//*[contains(concat(' ', normalize-space(@class), ' '), %s)]", classLiteral)) 325 + } 326 + 327 + func removeDefaultNonContentNodes(root *exhtml.Node) { 328 + for _, xp := range []string{ 329 + ".//script", 330 + ".//style", 331 + ".//noscript", 332 + } { 333 + removeNodesByXPath(root, xp) 334 + } 335 + } 336 + 337 + func normalizeWhitespace(value string) string { 338 + value = strings.ReplaceAll(value, "\u00a0", " ") 339 + return strings.TrimSpace(value) 340 + } 341 + 342 + func buildXPathLiteral(value string) string { 343 + if !strings.Contains(value, "'") { 344 + return "'" + value + "'" 345 + } 346 + 347 + if !strings.Contains(value, "\"") { 348 + return `"` + value + `"` 349 + } 350 + 351 + segments := strings.Split(value, "'") 352 + var builder strings.Builder 353 + builder.WriteString("concat(") 354 + 355 + for i, segment := range segments { 356 + if i > 0 { 357 + builder.WriteString(", \"'\", ") 358 + } 359 + if segment == "" { 360 + builder.WriteString("''") 361 + continue 362 + } 363 + builder.WriteString("'") 364 + builder.WriteString(segment) 365 + builder.WriteString("'") 366 + } 367 + 368 + builder.WriteString(")") 369 + return builder.String() 370 + } 371 + 239 372 // Convert HTML content directly to markdown using domain-specific rules 240 373 func (p *ArticleParser) Convert(htmlContent, domain, sourceURL string) (string, error) { 241 374 content, err := p.Parse(htmlContent, domain, sourceURL) ··· 268 401 269 402 baseMarkdownPath := filepath.Join(dir, slug+".md") 270 403 baseHTMLPath := filepath.Join(dir, slug+".html") 271 - 272 404 markdownPath = baseMarkdownPath 273 405 htmlPath = baseHTMLPath 274 406
+16 -13
internal/articles/parser_test.go
··· 218 218 if err == nil { 219 219 t.Error("Expected error when no title can be extracted") 220 220 } 221 - if !strings.Contains(err.Error(), "could not extract title") { 222 - t.Errorf("Expected 'could not extract title' error, got %v", err) 221 + if !strings.Contains(err.Error(), "could not extract title") && 222 + !strings.Contains(err.Error(), "could not extract body content") { 223 + t.Errorf("Expected title or body extraction error, got %v", err) 223 224 } 224 225 }) 225 226 ··· 229 230 <body> 230 231 <h1 id="firstHeading">Test Article Title</h1> 231 232 <div id="bodyContent"> 233 + <style>.mw-parser-output .hatnote{font-style:italic;}</style> 232 234 <p>This is the main content of the article.</p> 233 235 <div class="noprint">This should be stripped</div> 236 + <div class="editsection">Edit this section</div> 234 237 <p>More content here.</p> 235 238 </div> 236 239 </body> ··· 253 256 if strings.Contains(markdown, "This should be stripped") { 254 257 t.Error("Expected stripped content to be removed from markdown") 255 258 } 259 + if strings.Contains(markdown, ".mw-parser-output") { 260 + t.Error("Expected style content to be removed from markdown") 261 + } 262 + if strings.Contains(markdown, "Edit this section") { 263 + t.Error("Expected edit section markers to be removed from markdown") 264 + } 256 265 }) 257 266 }) 258 267 ··· 265 274 w.WriteHeader(http.StatusOK) 266 275 w.Write([]byte("<html><head><title>Test</title></head><body><p>Content</p></body></html>")) 267 276 default: 268 - // Return Wikipedia-like structure for localhost rule 269 277 w.WriteHeader(http.StatusOK) 270 278 w.Write([]byte(`<html> 271 279 <head><title>Test Article</title></head> ··· 555 563 } 556 564 }) 557 565 558 - t.Run("fails with invalid directory", func(t *testing.T) { 559 - // Skip this test as it would require network access to test with real URLs 560 - t.Skip("Skipping invalid directory test - requires network access") 561 - }) 562 - 563 566 t.Run("fails with malformed HTML", func(t *testing.T) { 564 567 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 565 568 w.WriteHeader(http.StatusOK) ··· 584 587 if err == nil { 585 588 t.Error("Expected error for malformed HTML") 586 589 } 587 - if !strings.Contains(err.Error(), "failed to parse HTML") && !strings.Contains(err.Error(), "could not extract title") { 588 - t.Errorf("Expected HTML parsing or title extraction error, got %v", err) 590 + if !strings.Contains(err.Error(), "failed to parse HTML") && 591 + !strings.Contains(err.Error(), "could not extract title") && 592 + !strings.Contains(err.Error(), "could not extract body content") { 593 + t.Errorf("Expected HTML parsing or extraction error, got %v", err) 589 594 } 590 595 }) 591 596 ··· 599 604 <p>Content without proper title</p> 600 605 </div> 601 606 </body> 602 - </html>`)) // No h1 with id="firstHeading" 607 + </html>`)) 603 608 })) 604 609 defer server.Close() 605 610 ··· 664 669 t.Fatalf("Failed to save article: %v", err) 665 670 } 666 671 667 - // Test that it creates a proper models.Article structure (simulating CreateArticleFromURL) 668 672 article := &models.Article{ 669 673 URL: server.URL, 670 674 Title: content.Title, ··· 779 783 t.Fatalf("Failed to save article: %v", err) 780 784 } 781 785 782 - // Verify markdown contains all metadata 783 786 mdContent, err := os.ReadFile(mdPath) 784 787 if err != nil { 785 788 t.Fatalf("Failed to read markdown file: %v", err)
+96 -59
internal/handlers/articles.go
··· 14 14 "github.com/stormlightlabs/noteleaf/internal/repo" 15 15 "github.com/stormlightlabs/noteleaf/internal/store" 16 16 "github.com/stormlightlabs/noteleaf/internal/ui" 17 - "github.com/stormlightlabs/noteleaf/internal/utils" 18 17 ) 19 18 19 + const ( 20 + articleUserAgent = "curl/8.4.0" 21 + articleAcceptHeader = "*/*" 22 + articleLangHeader = "en-US,en;q=0.8" 23 + ) 24 + 25 + type headerRoundTripper struct { 26 + rt http.RoundTripper 27 + } 28 + 20 29 // ArticleHandler handles all article-related commands 21 30 type ArticleHandler struct { 22 31 db *store.Database ··· 38 47 } 39 48 40 49 repos := repo.NewRepositories(db.DB) 41 - 42 - parser, err := articles.NewArticleParser(http.DefaultClient) 50 + parser, err := articles.NewArticleParser(newArticleHTTPClient()) 43 51 if err != nil { 44 52 return nil, fmt.Errorf("failed to initialize article parser: %w", err) 45 53 } ··· 52 60 }, nil 53 61 } 54 62 63 + func 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 + 76 + func (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 + 55 103 // Close cleans up resources 56 104 func (h *ArticleHandler) Close() error { 57 105 if h.db != nil { ··· 62 110 63 111 // Add handles adding an article from a URL 64 112 func (h *ArticleHandler) Add(ctx context.Context, url string) error { 65 - logger := utils.GetLogger() 66 - 67 113 existing, err := h.repos.Articles.GetByURL(ctx, url) 68 114 if err == nil { 69 - fmt.Printf("Article already exists: %s (ID: %d)\n", ui.TitleColorStyle.Render(existing.Title), existing.ID) 115 + ui.Warningln("Article already exists: %s (ID: %d)", ui.TitleColorStyle.Render(existing.Title), existing.ID) 70 116 return nil 71 117 } 72 118 73 - logger.Info("Parsing article", "url", url) 74 - fmt.Printf("Parsing article from: %s\n", url) 119 + ui.Infoln("Parsing article from: %s", url) 75 120 76 121 dir, err := h.getStorageDirectory() 77 122 if err != nil { ··· 106 151 return fmt.Errorf("failed to save article to database: %w", err) 107 152 } 108 153 109 - fmt.Printf("Article saved successfully!\n") 110 - fmt.Printf("ID: %d\n", id) 111 - fmt.Printf("Title: %s\n", ui.TitleColorStyle.Render(article.Title)) 154 + ui.Infoln("Article saved successfully!") 155 + ui.Infoln("ID: %d", id) 156 + ui.Infoln("Title: %s", ui.TitleColorStyle.Render(article.Title)) 112 157 if article.Author != "" { 113 - fmt.Printf("Author: %s\n", ui.HeaderColorStyle.Render(article.Author)) 158 + ui.Infoln("Author: %s", ui.HeaderColorStyle.Render(article.Author)) 114 159 } 115 160 if article.Date != "" { 116 - fmt.Printf("Date: %s\n", article.Date) 161 + ui.Infoln("Date: %s", article.Date) 117 162 } 118 - fmt.Printf("Markdown: %s\n", article.MarkdownPath) 119 - fmt.Printf("HTML: %s\n", article.HTMLPath) 120 - 121 - logger.Info("Article saved", "id", id, "title", article.Title) 163 + ui.Infoln("Markdown: %s", article.MarkdownPath) 164 + ui.Infoln("HTML: %s", article.HTMLPath) 122 165 123 166 return nil 124 167 } ··· 137 180 } 138 181 139 182 if len(articles) == 0 { 140 - fmt.Println("No articles found.") 183 + ui.Warningln("No articles found.") 141 184 return nil 142 185 } 143 186 144 - fmt.Printf("Found %d article(s):\n\n", len(articles)) 145 - 187 + ui.Infoln("Found %d article(s):\n", len(articles)) 146 188 for _, article := range articles { 147 - fmt.Printf("ID: %d\n", article.ID) 148 - fmt.Printf("Title: %s\n", ui.TitleColorStyle.Render(article.Title)) 189 + ui.Infoln("ID: %d", article.ID) 190 + ui.Infoln("Title: %s", ui.TitleColorStyle.Render(article.Title)) 149 191 if article.Author != "" { 150 - fmt.Printf("Author: %s\n", ui.HeaderColorStyle.Render(article.Author)) 192 + ui.Infoln("Author: %s", ui.HeaderColorStyle.Render(article.Author)) 151 193 } 152 194 if article.Date != "" { 153 - fmt.Printf("Date: %s\n", article.Date) 195 + ui.Infoln("Date: %s", article.Date) 154 196 } 155 - fmt.Printf("URL: %s\n", article.URL) 156 - fmt.Printf("Added: %s\n", article.Created.Format("2006-01-02 15:04:05")) 157 - fmt.Println("---") 197 + ui.Infoln("URL: %s", article.URL) 198 + ui.Infoln("Added: %s", article.Created.Format("2006-01-02 15:04:05")) 199 + ui.Plainln("---") 158 200 } 159 - 160 201 return nil 161 202 } 162 203 163 204 // View handles viewing an article by ID 164 205 func (h *ArticleHandler) View(ctx context.Context, id int64) error { 165 - 166 206 article, err := h.repos.Articles.Get(ctx, id) 167 207 if err != nil { 168 208 return fmt.Errorf("failed to get article: %w", err) 169 209 } 170 210 171 - fmt.Printf("Title: %s\n", ui.TitleColorStyle.Render(article.Title)) 211 + ui.Infoln("Title: %s", ui.TitleColorStyle.Render(article.Title)) 172 212 if article.Author != "" { 173 - fmt.Printf("Author: %s\n", ui.HeaderColorStyle.Render(article.Author)) 213 + ui.Infoln("Author: %s", ui.HeaderColorStyle.Render(article.Author)) 174 214 } 175 215 if article.Date != "" { 176 - fmt.Printf("Date: %s\n", article.Date) 216 + ui.Infoln("Date: %s", article.Date) 177 217 } 178 - fmt.Printf("URL: %s\n", article.URL) 179 - fmt.Printf("Added: %s\n", article.Created.Format("2006-01-02 15:04:05")) 180 - fmt.Printf("Modified: %s\n", article.Modified.Format("2006-01-02 15:04:05")) 181 - fmt.Println() 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() 182 222 183 - fmt.Printf("Markdown file: %s", article.MarkdownPath) 223 + ui.Info("Markdown file: %s", article.MarkdownPath) 184 224 if _, err := os.Stat(article.MarkdownPath); os.IsNotExist(err) { 185 - fmt.Printf(" (file not found)") 225 + ui.Warning(" (file not found)") 186 226 } 187 - fmt.Println() 227 + 228 + ui.Newline() 188 229 189 - fmt.Printf("HTML file: %s", article.HTMLPath) 230 + ui.Info("HTML file: %s", article.HTMLPath) 190 231 if _, err := os.Stat(article.HTMLPath); os.IsNotExist(err) { 191 - fmt.Printf(" (file not found)") 232 + ui.Warning(" (file not found)") 192 233 } 193 - fmt.Println() 234 + ui.Newline() 194 235 195 236 if _, err := os.Stat(article.MarkdownPath); err == nil { 196 - fmt.Printf("\n%s\n", ui.HeaderColorStyle.Render("--- Content Preview ---")) 237 + ui.Headerln("--- Content Preview ---") 197 238 content, err := os.ReadFile(article.MarkdownPath) 198 239 if err == nil { 199 240 lines := strings.Split(string(content), "\n") 200 241 previewLines := min(len(lines), 20) 201 242 202 243 for i := range previewLines { 203 - fmt.Println(lines[i]) 244 + ui.Plainln("%v", lines[i]) 204 245 } 205 246 206 247 if len(lines) > previewLines { 207 - fmt.Printf("\n... (%d more lines)\n", len(lines)-previewLines) 208 - fmt.Printf("Read full content: %s\n", article.MarkdownPath) 248 + ui.Plainln("\n... (%d more lines)", len(lines)-previewLines) 249 + ui.Plainln("Read full content: %s", article.MarkdownPath) 209 250 } 210 251 } 211 252 } ··· 227 268 228 269 if _, err := os.Stat(article.MarkdownPath); err == nil { 229 270 if rmErr := os.Remove(article.MarkdownPath); rmErr != nil { 230 - fmt.Printf("Warning: failed to remove markdown file: %v\n", rmErr) 271 + ui.Warningln("Warning: failed to remove markdown file: %v", rmErr) 231 272 } 232 273 } 233 274 234 275 if _, err := os.Stat(article.HTMLPath); err == nil { 235 276 if rmErr := os.Remove(article.HTMLPath); rmErr != nil { 236 - fmt.Printf("Warning: failed to remove HTML file: %v\n", rmErr) 277 + ui.Warningln("Warning: failed to remove HTML file: %v", rmErr) 237 278 } 238 279 } 239 280 240 - fmt.Printf("Article removed: %s (ID: %d)\n", ui.TitleColorStyle.Render(article.Title), id) 241 - 281 + ui.Titleln("Article removed: %s (ID: %d)", article.Title, id) 242 282 return nil 243 283 } 244 284 ··· 246 286 func (h *ArticleHandler) Help() error { 247 287 domains := h.parser.GetSupportedDomains() 248 288 249 - fmt.Println() 289 + ui.Newline() 250 290 251 291 if len(domains) > 0 { 252 - fmt.Printf("%s\n", ui.HeaderColorStyle.Render(fmt.Sprintf("Supported sites (%d):", len(domains)))) 292 + ui.Headerln("Supported sites (%d):", len(domains)) 253 293 for _, domain := range domains { 254 - fmt.Printf(" - %s\n", domain) 294 + ui.Plainln(" - %s", domain) 255 295 } 256 296 } else { 257 - fmt.Println("No parsing rules loaded.") 297 + ui.Plainln("No parsing rules loaded.") 258 298 } 259 299 260 - fmt.Println() 300 + ui.Newline() 261 301 dir, err := h.getStorageDirectory() 262 302 if err != nil { 263 303 return fmt.Errorf("failed to get storage directory: %w", err) 264 304 } 265 - fmt.Printf("%s %s\n", ui.HeaderColorStyle.Render("Storage directory:"), dir) 305 + ui.Headerln("%s %s", ui.HeaderColorStyle.Render("Storage directory:"), dir) 266 306 267 307 return nil 268 308 } ··· 293 333 } 294 334 295 335 func (h *ArticleHandler) getStorageDirectory() (string, error) { 296 - // Check config first 297 336 if h.config.ArticlesDir != "" { 298 337 return h.config.ArticlesDir, nil 299 338 } 300 339 301 - // Fall back to data directory 302 340 dataDir, err := store.GetDataDir() 303 341 if err != nil { 304 342 return "", err 305 343 } 306 - 307 344 return filepath.Join(dataDir, "articles"), nil 308 345 }
+4 -8
internal/handlers/seed.go
··· 62 62 63 63 fmt.Println("Seeding database with test data...") 64 64 65 - // Seed tasks 66 65 tasks := []struct { 67 66 description string 68 67 project string ··· 82 81 } 83 82 } 84 83 85 - // Seed books 86 84 books := []struct { 87 85 title string 88 86 author string ··· 103 101 } 104 102 105 103 fmt.Printf("Successfully seeded database with %d tasks and %d books\n", len(tasks), len(books)) 106 - fmt.Printf("\n%s\n", ui.Info.Render("Example commands to try:")) 107 - fmt.Printf(" %s\n", ui.Success.Render("noteleaf todo list")) 108 - fmt.Printf(" %s\n", ui.Success.Render("noteleaf media book list")) 109 - fmt.Printf(" %s\n", ui.Success.Render("noteleaf todo view 1")) 104 + fmt.Printf("\n%s\n", ui.InfoStyle.Render("Example commands to try:")) 105 + fmt.Printf(" %s\n", ui.SuccessStyle.Render("noteleaf todo list")) 106 + fmt.Printf(" %s\n", ui.SuccessStyle.Render("noteleaf media book list")) 107 + fmt.Printf(" %s\n", ui.SuccessStyle.Render("noteleaf todo view 1")) 110 108 111 109 return nil 112 110 } ··· 131 129 } 132 130 133 131 func (h *SeedHandler) seedTask(description, project, priority, status string) error { 134 - // Generate a simple UUID for the task (required field) 135 132 uuid := h.generateSimpleUUID() 136 133 query := `INSERT INTO tasks (uuid, description, project, priority, status, entry, modified) 137 134 VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'))` ··· 149 146 // generateSimpleUUID creates a simple UUID for seeding (not cryptographically secure, but sufficient for test data) 150 147 func (h *SeedHandler) generateSimpleUUID() string { 151 148 now := time.Now() 152 - // Add random component to avoid collisions during rapid seeding 153 149 randomNum := rand.Intn(10000) 154 150 return fmt.Sprintf("seed-task-%d-%d-%d", now.Unix(), now.UnixNano()%1000000, randomNum) 155 151 }
-7
internal/ui/colors.go
··· 8 8 "image/color" 9 9 "slices" 10 10 11 - "github.com/charmbracelet/lipgloss" 12 11 "github.com/lucasb-eyer/go-colorful" 13 - ) 14 - 15 - var ( 16 - TitleColorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true) 17 - SelectedColorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("212")) 18 - HeaderColorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Bold(true) 19 12 ) 20 13 21 14 var _ color.Color = Key(0)
+113
internal/ui/common.go
··· 1 + // TODO: create variants of colored output without icons 2 + // TODO: refactor existing (relevant) calls to old styles 3 + // TODO: k v wrappers 4 + package ui 5 + 6 + import ( 7 + "fmt" 8 + 9 + "github.com/charmbracelet/lipgloss" 10 + ) 11 + 12 + func newStyle() lipgloss.Style { return lipgloss.NewStyle() } 13 + func newPStyle(v, h int) lipgloss.Style { return lipgloss.NewStyle().Padding(v, h) } 14 + func newBoldStyle() lipgloss.Style { return newStyle().Bold(true) } 15 + func newPBoldStyle(v, h int) lipgloss.Style { return newPStyle(v, h).Bold(true) } 16 + func newEmStyle() lipgloss.Style { return newStyle().Italic(true) } 17 + 18 + func success(msg string) string { return SuccessStyle.Render("โœ“ " + msg) } 19 + func errorMsg(msg string) string { return ErrorStyle.Render("โœ— " + msg) } 20 + func warning(msg string) string { return WarningStyle.Render("โš  " + msg) } 21 + func info(msg string) string { return InfoStyle.Render("โ„น " + msg) } 22 + func title(msg string) string { return TitleStyle.Render(msg) } 23 + func subtitle(msg string) string { return SubtitleStyle.Render(msg) } 24 + func box(content string) string { return BoxStyle.Render(content) } 25 + func errorBox(content string) string { return ErrorBoxStyle.Render(content) } 26 + func text(content string) string { return TextStyle.Render(content) } 27 + func header(content string) string { return HeaderStyle.Render(content) } 28 + 29 + // Success prints a formatted success message 30 + func Success(format string, a ...any) { 31 + fmt.Print(success(fmt.Sprintf(format, a...))) 32 + } 33 + 34 + // Successln prints a formatted success message with a newline 35 + func Successln(format string, a ...any) { 36 + fmt.Println(success(fmt.Sprintf(format, a...))) 37 + } 38 + 39 + // Error prints a formatted error message 40 + func Error(format string, a ...any) { 41 + fmt.Print(errorMsg(fmt.Sprintf(format, a...))) 42 + } 43 + 44 + // Errorln prints a formatted error message with a newline 45 + func Errorln(format string, a ...any) { 46 + fmt.Println(errorMsg(fmt.Sprintf(format, a...))) 47 + } 48 + 49 + // Warning prints a formatted warning message 50 + func Warning(format string, a ...any) { 51 + fmt.Print(warning(fmt.Sprintf(format, a...))) 52 + } 53 + 54 + // Warningln prints a formatted warning message with a newline 55 + func Warningln(format string, a ...any) { 56 + fmt.Println(warning(fmt.Sprintf(format, a...))) 57 + } 58 + 59 + // Info prints a formatted info message 60 + func Info(format string, a ...any) { 61 + fmt.Print(info(fmt.Sprintf(format, a...))) 62 + } 63 + 64 + // Infoln prints a formatted info message with a newline 65 + func Infoln(format string, a ...any) { 66 + fmt.Println(info(fmt.Sprintf(format, a...))) 67 + } 68 + 69 + // Title prints a formatted title 70 + func Title(format string, a ...any) { 71 + fmt.Print(title(fmt.Sprintf(format, a...))) 72 + } 73 + 74 + // Titleln prints a formatted title with a newline 75 + func Titleln(format string, a ...any) { 76 + fmt.Println(title(fmt.Sprintf(format, a...))) 77 + } 78 + 79 + // Subtitle prints a formatted subtitle 80 + func Subtitle(format string, a ...any) { 81 + fmt.Print(subtitle(fmt.Sprintf(format, a...))) 82 + } 83 + 84 + // Subtitleln prints a formatted subtitle with a newline 85 + func Subtitleln(format string, a ...any) { 86 + fmt.Println(subtitle(fmt.Sprintf(format, a...))) 87 + } 88 + 89 + // Box prints content in a styled box 90 + func Box(format string, a ...any) { 91 + fmt.Print(box(fmt.Sprintf(format, a...))) 92 + } 93 + 94 + // Boxln prints content in a styled box with a newline 95 + func Boxln(format string, a ...any) { 96 + fmt.Println(box(fmt.Sprintf(format, a...))) 97 + } 98 + 99 + // ErrorBox prints error content in a styled error box 100 + func ErrorBox(format string, a ...any) { 101 + fmt.Print(errorBox(fmt.Sprintf(format, a...))) 102 + } 103 + 104 + // ErrorBoxln prints error content in a styled error box with a newline 105 + func ErrorBoxln(format string, a ...any) { 106 + fmt.Println(errorBox(fmt.Sprintf(format, a...))) 107 + } 108 + 109 + func Newline() { fmt.Println() } 110 + func Plain(format string, a ...any) { fmt.Print(text(fmt.Sprintf(format, a...))) } 111 + func Plainln(format string, a ...any) { fmt.Println(text(fmt.Sprintf(format, a...))) } 112 + func Header(format string, a ...any) { fmt.Print(header(fmt.Sprintf(format, a...))) } 113 + func Headerln(format string, a ...any) { fmt.Print(header(fmt.Sprintf(format, a...))) }
+2 -1
internal/ui/logo.go
··· 1 1 // See https://patorjk.com/software/taag/ 2 + // 3 + // NOTE: these aren't used anymore but are left in because they're cool 2 4 package ui 3 5 4 6 import ( ··· 78 80 } 79 81 80 82 // Colored returns a colored version of the logo using lipgloss with vertical spiral design 81 - // 82 83 // Creates a vertical spiral effect by coloring character by character: 83 84 // 84 85 // Combine line position and character position & use modulo to build wave-like transitions
+44 -106
internal/ui/palette.go
··· 8 8 lipglossv2 "github.com/charmbracelet/lipgloss/v2" 9 9 ) 10 10 11 - var PrimaryColors []Key = []Key{ 12 - Guac, 13 - Julep, 14 - Bok, 15 - Pickle, 16 - NeueGuac, 17 - } 18 - 19 - var SecondaryColors []Key = []Key{ 20 - Malibu, 21 - Sardine, 22 - Lichen, 23 - } 24 - 25 - var TertiaryColors []Key = []Key{ 26 - Violet, 27 - Mauve, 28 - Plum, 29 - Orchid, 30 - Charple, 31 - Hazy, 32 - } 33 - 34 - var ProvisionalColors []Key = []Key{NeueGuac, NeueZinc} 35 - 36 - var AdditionColors []Key = []Key{Pickle, Gator, Spinach} 37 - 38 - var DeletionColors []Key = []Key{Pom, Steak, Toast} 11 + var ( 12 + PrimaryColors = []Key{Guac, Julep, Bok, Pickle, NeueGuac} 13 + SecondaryColors = []Key{Malibu, Sardine, Lichen} 14 + TertiaryColors = []Key{Violet, Mauve, Plum, Orchid, Charple, Hazy} 15 + ProvisionalColors = []Key{NeueGuac, NeueZinc} 16 + AdditionColors = []Key{Pickle, Gator, Spinach} 17 + DeletionColors = []Key{Pom, Steak, Toast} 18 + ) 39 19 40 20 var NoteleafColorScheme fang.ColorSchemeFunc = noteleafColorScheme 41 21 ··· 73 53 } 74 54 75 55 var ( 76 - Success = lipgloss.NewStyle(). 77 - Foreground(lipgloss.Color(Julep.Hex())). 78 - Bold(true) 79 - 80 - Error = lipgloss.NewStyle(). 81 - Foreground(lipgloss.Color(Cherry.Hex())). 82 - Bold(true) 83 - 84 - Info = lipgloss.NewStyle(). 85 - Foreground(lipgloss.Color(Malibu.Hex())) 86 - 87 - Warning = lipgloss.NewStyle(). 88 - Foreground(lipgloss.Color(Citron.Hex())). 89 - Bold(true) 90 - 91 - Path = lipgloss.NewStyle(). 92 - Foreground(lipgloss.Color(Mustard.Hex())). 93 - Italic(true) 94 - ) 95 - 96 - var ( 97 - TaskTitle = lipgloss.NewStyle(). 98 - Foreground(lipgloss.Color(Salt.Hex())). 99 - Bold(true) 100 - 101 - TaskID = lipgloss.NewStyle(). 102 - Foreground(lipgloss.Color(Squid.Hex())). 103 - Width(8) 104 - ) 105 - 106 - var ( 107 - StatusPending = lipgloss.NewStyle(). 108 - Foreground(lipgloss.Color(Citron.Hex())) 109 - 110 - StatusCompleted = lipgloss.NewStyle(). 111 - Foreground(lipgloss.Color(Julep.Hex())) 112 - ) 113 - 114 - var ( 115 - PriorityHigh = lipgloss.NewStyle(). 116 - Foreground(lipgloss.Color(Cherry.Hex())). 117 - Bold(true) 56 + ColorPrimary = Thunder.Hex() // Blue 57 + ColorAccent = Cumin.Hex() // Yellow/Gold 58 + ColorError = Paprika.Hex() // Red/Pink 59 + ColorText = Salt.Hex() // Light text 60 + ColorBG = Pepper.Hex() // Dark background 118 61 119 - PriorityMedium = lipgloss.NewStyle(). 120 - Foreground(lipgloss.Color(Citron.Hex())) 62 + PrimaryStyle = newStyle().Foreground(lipgloss.Color(ColorPrimary)) 63 + AccentStyle = newStyle().Foreground(lipgloss.Color(ColorAccent)) 64 + ErrorStyle = newStyle().Foreground(lipgloss.Color(ColorError)) 65 + TextStyle = newStyle().Foreground(lipgloss.Color(ColorText)) 66 + TitleStyle = newPBoldStyle(0, 1).Foreground(lipgloss.Color(ColorAccent)) 67 + SubtitleStyle = newEmStyle().Foreground(lipgloss.Color(ColorPrimary)) 68 + SuccessStyle = newBoldStyle().Foreground(lipgloss.Color(ColorPrimary)) 69 + WarningStyle = newBoldStyle().Foreground(lipgloss.Color(ColorAccent)) 70 + InfoStyle = newStyle().Foreground(lipgloss.Color(ColorText)) 71 + BoxStyle = newPStyle(1, 2).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(ColorPrimary)) 72 + ErrorBoxStyle = newPStyle(1, 2).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(ColorError)) 73 + ListItemStyle = newStyle().Foreground(lipgloss.Color(ColorText)).PaddingLeft(2) 74 + SelectedItemStyle = newBoldStyle().Foreground(lipgloss.Color(ColorAccent)).PaddingLeft(2) 75 + HeaderStyle = newPBoldStyle(0, 1).Foreground(lipgloss.Color(ColorPrimary)) 76 + CellStyle = newPStyle(0, 1).Foreground(lipgloss.Color(ColorText)) 121 77 122 - PriorityLow = lipgloss.NewStyle(). 123 - Foreground(lipgloss.Color(Squid.Hex())) 124 - ) 78 + TaskTitleStyle = newBoldStyle().Foreground(lipgloss.Color(Salt.Hex())) 79 + TaskIDStyle = newStyle().Foreground(lipgloss.Color(Squid.Hex())).Width(8) 125 80 126 - var ( 127 - MovieStyle = lipgloss.NewStyle(). 128 - Foreground(lipgloss.Color(Coral.Hex())). 129 - Bold(true) 81 + StatusPending = newStyle().Foreground(lipgloss.Color(Citron.Hex())) 82 + StatusCompleted = newStyle().Foreground(lipgloss.Color(Julep.Hex())) 130 83 131 - TVStyle = lipgloss.NewStyle(). 132 - Foreground(lipgloss.Color(Violet.Hex())). 133 - Bold(true) 84 + PriorityHigh = newBoldStyle().Foreground(lipgloss.Color(Cherry.Hex())) 85 + PriorityMedium = newStyle().Foreground(lipgloss.Color(Citron.Hex())) 86 + PriorityLow = newStyle().Foreground(lipgloss.Color(Squid.Hex())) 134 87 135 - BookStyle = lipgloss.NewStyle(). 136 - Foreground(lipgloss.Color(Guac.Hex())). 137 - Bold(true) 88 + MovieStyle = newBoldStyle().Foreground(lipgloss.Color(Coral.Hex())) 89 + TVStyle = newBoldStyle().Foreground(lipgloss.Color(Violet.Hex())) 90 + BookStyle = newBoldStyle().Foreground(lipgloss.Color(Guac.Hex())) 91 + MusicStyle = newBoldStyle().Foreground(lipgloss.Color(Lichen.Hex())) 138 92 139 - MusicStyle = lipgloss.NewStyle(). 140 - Foreground(lipgloss.Color(Lichen.Hex())). 141 - Bold(true) 142 - ) 143 - 144 - // Table and UI styles 145 - var ( 146 - TableStyle = lipgloss.NewStyle(). 147 - BorderStyle(lipgloss.NormalBorder()). 148 - BorderForeground(lipgloss.Color(Smoke.Hex())) 149 - 150 - SelectedStyle = lipgloss.NewStyle(). 151 - Foreground(lipgloss.Color(Salt.Hex())). 152 - Background(lipgloss.Color(Squid.Hex())). 153 - Bold(true) 154 - 155 - HeaderStyle = lipgloss.NewStyle(). 156 - BorderStyle(lipgloss.NormalBorder()). 157 - BorderForeground(lipgloss.Color(Smoke.Hex())). 158 - BorderBottom(true). 159 - Bold(false) 93 + TableStyle = newStyle().BorderStyle(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color(Smoke.Hex())) 94 + SelectedStyle = newBoldStyle().Foreground(lipgloss.Color(Salt.Hex())).Background(lipgloss.Color(Squid.Hex())) 95 + TitleColorStyle = newBoldStyle().Foreground(lipgloss.Color("212")) 96 + SelectedColorStyle = newBoldStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("212")) 97 + HeaderColorStyle = newBoldStyle().Foreground(lipgloss.Color("240")) 160 98 )