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

feat: static article handlers

+1210 -4
+99 -2
cmd/commands.go
··· 26 Create() *cobra.Command 27 } 28 29 - // MovieCommand implements CommandGroup for movie-related commands 30 type MovieCommand struct { 31 handler *handlers.MovieHandler 32 } ··· 193 return root 194 } 195 196 - // BookCommand implements CommandGroup for book-related commands 197 type BookCommand struct { 198 handler *handlers.BookHandler 199 } ··· 409 410 return root 411 }
··· 26 Create() *cobra.Command 27 } 28 29 + // MovieCommand implements [CommandGroup] for movie-related commands 30 type MovieCommand struct { 31 handler *handlers.MovieHandler 32 } ··· 193 return root 194 } 195 196 + // BookCommand implements [CommandGroup] for book-related commands 197 type BookCommand struct { 198 handler *handlers.BookHandler 199 } ··· 409 410 return root 411 } 412 + 413 + // ArticleCommand implements [CommandGroup] for article-related commands 414 + type ArticleCommand struct { 415 + handler *handlers.ArticleHandler 416 + } 417 + 418 + // NewArticleCommand creates a new ArticleCommand with the given handler 419 + func NewArticleCommand(handler *handlers.ArticleHandler) *ArticleCommand { 420 + return &ArticleCommand{handler: handler} 421 + } 422 + 423 + func (c *ArticleCommand) Create() *cobra.Command { 424 + root := &cobra.Command{Use: "article", Short: "Manage saved articles"} 425 + 426 + addCmd := &cobra.Command{ 427 + Use: "add <url>", 428 + Short: "Parse and save article from URL", 429 + Long: `Parse and save article content from a supported website. 430 + 431 + The article will be parsed using domain-specific XPath rules and saved 432 + as both Markdown and HTML files. Article metadata is stored in the database.`, 433 + Args: cobra.ExactArgs(1), 434 + RunE: func(cmd *cobra.Command, args []string) error { 435 + 436 + defer c.handler.Close() 437 + return c.handler.Add(cmd.Context(), args[0]) 438 + }, 439 + } 440 + root.AddCommand(addCmd) 441 + 442 + listCmd := &cobra.Command{ 443 + Use: "list [query]", 444 + Short: "List saved articles", 445 + Aliases: []string{"ls"}, 446 + Long: `List saved articles with optional filtering. 447 + 448 + Use query to filter by title, or use flags for more specific filtering.`, 449 + RunE: func(cmd *cobra.Command, args []string) error { 450 + author, _ := cmd.Flags().GetString("author") 451 + limit, _ := cmd.Flags().GetInt("limit") 452 + 453 + var query string 454 + if len(args) > 0 { 455 + query = strings.Join(args, " ") 456 + } 457 + 458 + defer c.handler.Close() 459 + return c.handler.List(cmd.Context(), query, author, limit) 460 + }, 461 + } 462 + listCmd.Flags().String("author", "", "Filter by author") 463 + listCmd.Flags().IntP("limit", "l", 0, "Limit number of results (0 = no limit)") 464 + root.AddCommand(listCmd) 465 + 466 + viewCmd := &cobra.Command{ 467 + Use: "view <id>", 468 + Short: "View article details and content preview", 469 + Aliases: []string{"show"}, 470 + Args: cobra.ExactArgs(1), 471 + RunE: func(cmd *cobra.Command, args []string) error { 472 + if articleID, err := parseID("article", args); err != nil { 473 + return err 474 + } else { 475 + defer c.handler.Close() 476 + return c.handler.View(cmd.Context(), articleID) 477 + } 478 + }, 479 + } 480 + root.AddCommand(viewCmd) 481 + 482 + removeCmd := &cobra.Command{ 483 + Use: "remove <id>", 484 + Short: "Remove article and associated files", 485 + Aliases: []string{"rm", "delete"}, 486 + Args: cobra.ExactArgs(1), 487 + RunE: func(cmd *cobra.Command, args []string) error { 488 + if articleID, err := parseID("article", args); err != nil { 489 + return err 490 + } else { 491 + defer c.handler.Close() 492 + return c.handler.Remove(cmd.Context(), articleID) 493 + } 494 + }, 495 + } 496 + root.AddCommand(removeCmd) 497 + 498 + originalHelpFunc := root.HelpFunc() 499 + root.SetHelpFunc(func(cmd *cobra.Command, args []string) { 500 + originalHelpFunc(cmd, args) 501 + 502 + fmt.Println() 503 + defer c.handler.Close() 504 + c.handler.Help() 505 + }) 506 + 507 + return root 508 + }
+131
cmd/commands_test.go
··· 100 } 101 } 102 103 func findSubcommand(commands []string, target string) bool { 104 return slices.Contains(commands, target) 105 } ··· 121 bookHandler, bookCleanup := createTestBookHandler(t) 122 defer bookCleanup() 123 124 var _ CommandGroup = NewTaskCommand(taskHandler) 125 var _ CommandGroup = NewMovieCommand(movieHandler) 126 var _ CommandGroup = NewTVCommand(tvHandler) 127 var _ CommandGroup = NewNoteCommand(noteHandler) 128 var _ CommandGroup = NewBookCommand(bookHandler) 129 }) 130 131 t.Run("Create", func(t *testing.T) { ··· 318 } 319 }) 320 321 t.Run("all command groups implement Create", func(t *testing.T) { 322 taskHandler, taskCleanup := createTestTaskHandler(t) 323 defer taskCleanup() ··· 334 bookHandler, bookCleanup := createTestBookHandler(t) 335 defer bookCleanup() 336 337 groups := []CommandGroup{ 338 NewTaskCommand(taskHandler), 339 NewMovieCommand(movieHandler), 340 NewTVCommand(tvHandler), 341 NewNoteCommand(noteHandler), 342 NewBookCommand(bookHandler), 343 } 344 345 for i, group := range groups { ··· 546 err := cmd.Execute() 547 if err == nil { 548 t.Error("expected book update command to fail with invalid status") 549 } 550 }) 551 })
··· 100 } 101 } 102 103 + func createTestArticleHandler(t *testing.T) (*handlers.ArticleHandler, func()) { 104 + cleanup := setupCommandTest(t) 105 + handler, err := handlers.NewArticleHandler() 106 + if err != nil { 107 + cleanup() 108 + t.Fatalf("Failed to create test article handler: %v", err) 109 + } 110 + return handler, func() { 111 + handler.Close() 112 + cleanup() 113 + } 114 + } 115 + 116 func findSubcommand(commands []string, target string) bool { 117 return slices.Contains(commands, target) 118 } ··· 134 bookHandler, bookCleanup := createTestBookHandler(t) 135 defer bookCleanup() 136 137 + articleHandler, articleCleanup := createTestArticleHandler(t) 138 + defer articleCleanup() 139 + 140 var _ CommandGroup = NewTaskCommand(taskHandler) 141 var _ CommandGroup = NewMovieCommand(movieHandler) 142 var _ CommandGroup = NewTVCommand(tvHandler) 143 var _ CommandGroup = NewNoteCommand(noteHandler) 144 var _ CommandGroup = NewBookCommand(bookHandler) 145 + var _ CommandGroup = NewArticleCommand(articleHandler) 146 }) 147 148 t.Run("Create", func(t *testing.T) { ··· 335 } 336 }) 337 338 + t.Run("ArticleCommand", func(t *testing.T) { 339 + handler, cleanup := createTestArticleHandler(t) 340 + defer cleanup() 341 + 342 + commands := NewArticleCommand(handler) 343 + cmd := commands.Create() 344 + 345 + if cmd == nil { 346 + t.Fatal("Create returned nil") 347 + } 348 + if cmd.Use != "article" { 349 + t.Errorf("Expected Use to be 'article', got '%s'", cmd.Use) 350 + } 351 + if cmd.Short != "Manage saved articles" { 352 + t.Errorf("Expected Short to be 'Manage saved articles', got '%s'", cmd.Short) 353 + } 354 + if !cmd.HasSubCommands() { 355 + t.Error("Expected command to have subcommands") 356 + } 357 + 358 + subcommands := cmd.Commands() 359 + subcommandNames := make([]string, len(subcommands)) 360 + for i, subcmd := range subcommands { 361 + subcommandNames[i] = subcmd.Use 362 + } 363 + 364 + for _, expected := range []string{"add <url>", "list [query]", "view <id>", "remove <id>"} { 365 + if !findSubcommand(subcommandNames, expected) { 366 + t.Errorf("Expected subcommand '%s' not found in %v", expected, subcommandNames) 367 + } 368 + } 369 + }) 370 + 371 t.Run("all command groups implement Create", func(t *testing.T) { 372 taskHandler, taskCleanup := createTestTaskHandler(t) 373 defer taskCleanup() ··· 384 bookHandler, bookCleanup := createTestBookHandler(t) 385 defer bookCleanup() 386 387 + articleHandler, articleCleanup := createTestArticleHandler(t) 388 + defer articleCleanup() 389 + 390 groups := []CommandGroup{ 391 NewTaskCommand(taskHandler), 392 NewMovieCommand(movieHandler), 393 NewTVCommand(tvHandler), 394 NewNoteCommand(noteHandler), 395 NewBookCommand(bookHandler), 396 + NewArticleCommand(articleHandler), 397 } 398 399 for i, group := range groups { ··· 600 err := cmd.Execute() 601 if err == nil { 602 t.Error("expected book update command to fail with invalid status") 603 + } 604 + }) 605 + }) 606 + 607 + t.Run("Article Commands", func(t *testing.T) { 608 + handler, cleanup := createTestArticleHandler(t) 609 + defer cleanup() 610 + 611 + t.Run("list command - default", func(t *testing.T) { 612 + cmd := NewArticleCommand(handler).Create() 613 + cmd.SetArgs([]string{"list"}) 614 + err := cmd.Execute() 615 + if err != nil { 616 + t.Errorf("article list command failed: %v", err) 617 + } 618 + }) 619 + 620 + t.Run("help command", func(t *testing.T) { 621 + cmd := NewArticleCommand(handler).Create() 622 + cmd.SetArgs([]string{"help"}) 623 + err := cmd.Execute() 624 + if err != nil { 625 + t.Errorf("article help command failed: %v", err) 626 + } 627 + }) 628 + 629 + t.Run("add command with empty args", func(t *testing.T) { 630 + cmd := NewArticleCommand(handler).Create() 631 + cmd.SetArgs([]string{"add"}) 632 + err := cmd.Execute() 633 + if err == nil { 634 + t.Error("expected article add command to fail with empty args") 635 + } 636 + }) 637 + 638 + t.Run("add command with invalid URL", func(t *testing.T) { 639 + cmd := NewArticleCommand(handler).Create() 640 + cmd.SetArgs([]string{"add", "not-a-url"}) 641 + err := cmd.Execute() 642 + if err == nil { 643 + t.Error("expected article add command to fail with invalid URL") 644 + } 645 + }) 646 + 647 + t.Run("view command with non-existent article ID", func(t *testing.T) { 648 + cmd := NewArticleCommand(handler).Create() 649 + cmd.SetArgs([]string{"view", "999"}) 650 + err := cmd.Execute() 651 + if err == nil { 652 + t.Error("expected article view command to fail with non-existent ID") 653 + } 654 + }) 655 + 656 + t.Run("view command with non-numeric ID", func(t *testing.T) { 657 + cmd := NewArticleCommand(handler).Create() 658 + cmd.SetArgs([]string{"view", "invalid"}) 659 + err := cmd.Execute() 660 + if err == nil { 661 + t.Error("expected article view command to fail with non-numeric ID") 662 + } 663 + }) 664 + 665 + t.Run("remove command with non-existent article ID", func(t *testing.T) { 666 + cmd := NewArticleCommand(handler).Create() 667 + cmd.SetArgs([]string{"remove", "999"}) 668 + err := cmd.Execute() 669 + if err == nil { 670 + t.Error("expected article remove command to fail with non-existent ID") 671 + } 672 + }) 673 + 674 + t.Run("remove command with non-numeric ID", func(t *testing.T) { 675 + cmd := NewArticleCommand(handler).Create() 676 + cmd.SetArgs([]string{"remove", "invalid"}) 677 + err := cmd.Execute() 678 + if err == nil { 679 + t.Error("expected article remove command to fail with non-numeric ID") 680 } 681 }) 682 })
+6
cmd/main.go
··· 164 log.Fatalf("failed to create book handler: %v", err) 165 } 166 167 root := rootCmd() 168 169 coreGroups := []CommandGroup{ 170 NewTaskCommand(taskHandler), 171 NewNoteCommand(noteHandler), 172 } 173 174 for _, group := range coreGroups {
··· 164 log.Fatalf("failed to create book handler: %v", err) 165 } 166 167 + articleHandler, err := handlers.NewArticleHandler() 168 + if err != nil { 169 + log.Fatalf("failed to create article handler: %v", err) 170 + } 171 + 172 root := rootCmd() 173 174 coreGroups := []CommandGroup{ 175 NewTaskCommand(taskHandler), 176 NewNoteCommand(noteHandler), 177 + NewArticleCommand(articleHandler), 178 } 179 180 for _, group := range coreGroups {
+27 -2
docs/cli.md
··· 22 Handlers are created once in `main.go` during application startup. Initialization errors prevent application launch rather than causing runtime failures. 23 Handlers persist for the application lifetime without requiring cleanup. Commands access handlers through struct fields rather than creating new instances. 24 25 - ### Testing Benefits 26 27 `CommandGroup` structs accept handlers as constructor parameters, enabling easy dependency injection of mock handlers for testing. 28 Command logic can be tested independently of handler implementations. The interface allows mocking entire command groups for integration testing. 29 30 - ### Registry Pattern 31 32 `main.go` uses a registry pattern to organize command groups by category. Core commands include task, note, and media functionality. 33 Management commands handle configuration, setup, and maintenance operations. The pattern provides clean separation and easy extension for new command groups.
··· 22 Handlers are created once in `main.go` during application startup. Initialization errors prevent application launch rather than causing runtime failures. 23 Handlers persist for the application lifetime without requiring cleanup. Commands access handlers through struct fields rather than creating new instances. 24 25 + ### Testing 26 27 `CommandGroup` structs accept handlers as constructor parameters, enabling easy dependency injection of mock handlers for testing. 28 Command logic can be tested independently of handler implementations. The interface allows mocking entire command groups for integration testing. 29 30 + ### Registry 31 32 `main.go` uses a registry pattern to organize command groups by category. Core commands include task, note, and media functionality. 33 Management commands handle configuration, setup, and maintenance operations. The pattern provides clean separation and easy extension for new command groups. 34 + 35 + ## UI and Styling System 36 + 37 + The application uses a structured color palette system located in `internal/ui/colors.go` for consistent terminal output styling. 38 + 39 + ### Color Architecture 40 + 41 + The color system implements a `Key` type with 74 predefined colors from the Charm ecosystem, including warm tones (Cumin, Tang, Paprika), cool tones (Sapphire, Oceania, Zinc), and neutral grays (Pepper through Butter). Each color provides hex values via the `Hex()` method and implements Go's `color.Color` interface through `RGBA()`. 42 + 43 + ### Predefined Styles 44 + 45 + Three core lipgloss styles handle common UI elements: 46 + 47 + - `TitleColorStyle` uses color 212 with bold formatting for command titles 48 + - `SelectedColorStyle` provides white-on-212 highlighting for selected items 49 + - `HeaderColorStyle` applies color 240 with bold formatting for section headers 50 + 51 + ### Color Categories 52 + 53 + Colors are organized into primary, secondary, and tertiary categories are accessed through `IsPrimary()`, `IsSecondary()`, and `IsTertiary()` methods on the `Key` type. 54 + 55 + ### Lipgloss Integration 56 + 57 + The styling system integrates with the Charmbracelet lipgloss library for terminal UI rendering. 58 + Colors from the `Key` type convert to lipgloss color values through their `Hex()` method. The predefined `TitleColorStyle`, `SelectedColorStyle`, and `HeaderColorStyle` variables provide lipgloss styles that can be applied to strings with `.Render()`.
+279
internal/handlers/articles.go
···
··· 1 + package handlers 2 + 3 + import ( 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 + "github.com/stormlightlabs/noteleaf/internal/utils" 18 + ) 19 + 20 + // ArticleHandler handles all article-related commands 21 + type ArticleHandler struct { 22 + db *store.Database 23 + config *store.Config 24 + repos *repo.Repositories 25 + parser articles.Parser 26 + } 27 + 28 + // NewArticleHandler creates a new article handler 29 + func NewArticleHandler() (*ArticleHandler, error) { 30 + db, err := store.NewDatabase() 31 + if err != nil { 32 + return nil, fmt.Errorf("failed to initialize database: %w", err) 33 + } 34 + 35 + config, err := store.LoadConfig() 36 + if err != nil { 37 + return nil, fmt.Errorf("failed to load configuration: %w", err) 38 + } 39 + 40 + repos := repo.NewRepositories(db.DB) 41 + 42 + parser, err := articles.NewArticleParser(http.DefaultClient) 43 + if err != nil { 44 + return nil, fmt.Errorf("failed to initialize article parser: %w", err) 45 + } 46 + 47 + return &ArticleHandler{ 48 + db: db, 49 + config: config, 50 + repos: repos, 51 + parser: parser, 52 + }, nil 53 + } 54 + 55 + // Close cleans up resources 56 + func (h *ArticleHandler) Close() error { 57 + if h.db != nil { 58 + return h.db.Close() 59 + } 60 + return nil 61 + } 62 + 63 + // Add handles adding an article from a URL 64 + func (h *ArticleHandler) Add(ctx context.Context, url string) error { 65 + logger := utils.GetLogger() 66 + 67 + existing, err := h.repos.Articles.GetByURL(ctx, url) 68 + if err == nil { 69 + fmt.Printf("Article already exists: %s (ID: %d)\n", ui.TitleColorStyle.Render(existing.Title), existing.ID) 70 + return nil 71 + } 72 + 73 + logger.Info("Parsing article", "url", url) 74 + fmt.Printf("Parsing article from: %s\n", url) 75 + 76 + dir, err := h.getStorageDirectory() 77 + if err != nil { 78 + return fmt.Errorf("failed to get article storage dir %w", err) 79 + } 80 + 81 + content, err := h.parser.ParseURL(url) 82 + if err != nil { 83 + return fmt.Errorf("failed to parse article: %w", err) 84 + } 85 + 86 + mdPath, htmlPath, err := h.parser.SaveArticle(content, dir) 87 + if err != nil { 88 + return fmt.Errorf("failed to save article: %w", err) 89 + } 90 + 91 + article := &models.Article{ 92 + URL: url, 93 + Title: content.Title, 94 + Author: content.Author, 95 + Date: content.Date, 96 + MarkdownPath: mdPath, 97 + HTMLPath: htmlPath, 98 + Created: time.Now(), 99 + Modified: time.Now(), 100 + } 101 + 102 + id, err := h.repos.Articles.Create(ctx, article) 103 + if err != nil { 104 + os.Remove(article.MarkdownPath) 105 + os.Remove(article.HTMLPath) 106 + return fmt.Errorf("failed to save article to database: %w", err) 107 + } 108 + 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)) 112 + if article.Author != "" { 113 + fmt.Printf("Author: %s\n", ui.HeaderColorStyle.Render(article.Author)) 114 + } 115 + if article.Date != "" { 116 + fmt.Printf("Date: %s\n", article.Date) 117 + } 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) 122 + 123 + return nil 124 + } 125 + 126 + // List handles listing articles with optional filtering 127 + func (h *ArticleHandler) List(ctx context.Context, query string, author string, limit int) error { 128 + opts := &repo.ArticleListOptions{ 129 + Title: query, 130 + Author: author, 131 + Limit: limit, 132 + } 133 + 134 + articles, err := h.repos.Articles.List(ctx, opts) 135 + if err != nil { 136 + return fmt.Errorf("failed to list articles: %w", err) 137 + } 138 + 139 + if len(articles) == 0 { 140 + fmt.Println("No articles found.") 141 + return nil 142 + } 143 + 144 + fmt.Printf("Found %d article(s):\n\n", len(articles)) 145 + 146 + for _, article := range articles { 147 + fmt.Printf("ID: %d\n", article.ID) 148 + fmt.Printf("Title: %s\n", ui.TitleColorStyle.Render(article.Title)) 149 + if article.Author != "" { 150 + fmt.Printf("Author: %s\n", ui.HeaderColorStyle.Render(article.Author)) 151 + } 152 + if article.Date != "" { 153 + fmt.Printf("Date: %s\n", article.Date) 154 + } 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("---") 158 + } 159 + 160 + return nil 161 + } 162 + 163 + // View handles viewing an article by ID 164 + func (h *ArticleHandler) View(ctx context.Context, id int64) error { 165 + 166 + article, err := h.repos.Articles.Get(ctx, id) 167 + if err != nil { 168 + return fmt.Errorf("failed to get article: %w", err) 169 + } 170 + 171 + fmt.Printf("Title: %s\n", ui.TitleColorStyle.Render(article.Title)) 172 + if article.Author != "" { 173 + fmt.Printf("Author: %s\n", ui.HeaderColorStyle.Render(article.Author)) 174 + } 175 + if article.Date != "" { 176 + fmt.Printf("Date: %s\n", article.Date) 177 + } 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() 182 + 183 + fmt.Printf("Markdown file: %s", article.MarkdownPath) 184 + if _, err := os.Stat(article.MarkdownPath); os.IsNotExist(err) { 185 + fmt.Printf(" (file not found)") 186 + } 187 + fmt.Println() 188 + 189 + fmt.Printf("HTML file: %s", article.HTMLPath) 190 + if _, err := os.Stat(article.HTMLPath); os.IsNotExist(err) { 191 + fmt.Printf(" (file not found)") 192 + } 193 + fmt.Println() 194 + 195 + if _, err := os.Stat(article.MarkdownPath); err == nil { 196 + fmt.Printf("\n%s\n", ui.HeaderColorStyle.Render("--- Content Preview ---")) 197 + content, err := os.ReadFile(article.MarkdownPath) 198 + if err == nil { 199 + lines := strings.Split(string(content), "\n") 200 + previewLines := min(len(lines), 20) 201 + 202 + for i := range previewLines { 203 + fmt.Println(lines[i]) 204 + } 205 + 206 + 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) 209 + } 210 + } 211 + } 212 + 213 + return nil 214 + } 215 + 216 + // Remove handles removing an article by ID 217 + func (h *ArticleHandler) Remove(ctx context.Context, id int64) error { 218 + article, err := h.repos.Articles.Get(ctx, id) 219 + if err != nil { 220 + return fmt.Errorf("failed to get article: %w", err) 221 + } 222 + 223 + err = h.repos.Articles.Delete(ctx, id) 224 + if err != nil { 225 + return fmt.Errorf("failed to remove article from database: %w", err) 226 + } 227 + 228 + if _, err := os.Stat(article.MarkdownPath); err == nil { 229 + if rmErr := os.Remove(article.MarkdownPath); rmErr != nil { 230 + fmt.Printf("Warning: failed to remove markdown file: %v\n", rmErr) 231 + } 232 + } 233 + 234 + if _, err := os.Stat(article.HTMLPath); err == nil { 235 + if rmErr := os.Remove(article.HTMLPath); rmErr != nil { 236 + fmt.Printf("Warning: failed to remove HTML file: %v\n", rmErr) 237 + } 238 + } 239 + 240 + fmt.Printf("Article removed: %s (ID: %d)\n", ui.TitleColorStyle.Render(article.Title), id) 241 + 242 + return nil 243 + } 244 + 245 + // Help shows supported domains (to complement default cobra/fang help) 246 + func (h *ArticleHandler) Help() error { 247 + domains := h.parser.GetSupportedDomains() 248 + 249 + fmt.Println() 250 + 251 + if len(domains) > 0 { 252 + fmt.Printf("%s\n", ui.HeaderColorStyle.Render(fmt.Sprintf("Supported sites (%d):", len(domains)))) 253 + for _, domain := range domains { 254 + fmt.Printf(" - %s\n", domain) 255 + } 256 + } else { 257 + fmt.Println("No parsing rules loaded.") 258 + } 259 + 260 + fmt.Println() 261 + dir, err := h.getStorageDirectory() 262 + if err != nil { 263 + return fmt.Errorf("failed to get storage directory: %w", err) 264 + } 265 + fmt.Printf("%s %s\n", ui.HeaderColorStyle.Render("Storage directory:"), dir) 266 + 267 + return nil 268 + } 269 + 270 + // TODO: Try to get from config first (could be added later) 271 + // For now, use default ~/Documents/Leaf/ 272 + func (h *ArticleHandler) getStorageDirectory() (string, error) { 273 + homeDir, err := os.UserHomeDir() 274 + if err != nil { 275 + return "", err 276 + } 277 + 278 + return filepath.Join(homeDir, "Documents", "Leaf"), nil 279 + }
+528
internal/handlers/articles_test.go
···
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "net/http/httptest" 7 + "runtime" 8 + "strings" 9 + "testing" 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 + ) 16 + 17 + func TestArticleHandler(t *testing.T) { 18 + t.Run("NewArticleHandler", func(t *testing.T) { 19 + t.Run("creates handler successfully", func(t *testing.T) { 20 + helper := NewArticleTestHelper(t) 21 + 22 + if helper.ArticleHandler == nil { 23 + t.Fatal("Handler should not be nil") 24 + } 25 + 26 + if helper.db == nil { 27 + t.Error("Handler database should not be nil") 28 + } 29 + if helper.config == nil { 30 + t.Error("Handler config should not be nil") 31 + } 32 + if helper.repos == nil { 33 + t.Error("Handler repos should not be nil") 34 + } 35 + if helper.parser == nil { 36 + t.Error("Handler parser should not be nil") 37 + } 38 + }) 39 + 40 + t.Run("handles database initialization error", func(t *testing.T) { 41 + envHelper := NewEnvironmentTestHelper() 42 + defer envHelper.RestoreEnv() 43 + 44 + if runtime.GOOS == "windows" { 45 + envHelper.UnsetEnv("APPDATA") 46 + } else { 47 + envHelper.UnsetEnv("XDG_CONFIG_HOME") 48 + envHelper.UnsetEnv("HOME") 49 + } 50 + 51 + _, err := NewArticleHandler() 52 + Expect.AssertError(t, err, "failed to initialize database", "NewArticleHandler should fail when database initialization fails") 53 + }) 54 + 55 + }) 56 + 57 + t.Run("Add", func(t *testing.T) { 58 + t.Run("adds article successfully", func(t *testing.T) { 59 + helper := NewArticleTestHelper(t) 60 + ctx := context.Background() 61 + 62 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 63 + w.WriteHeader(http.StatusOK) 64 + w.Write([]byte(`<html> 65 + <head><title>Test Article</title></head> 66 + <body> 67 + <h1 id="firstHeading">Test Article Title</h1> 68 + <div class="author">Test Author</div> 69 + <div class="date">2024-01-01</div> 70 + <div id="bodyContent"> 71 + <p>This is test content for the article.</p> 72 + </div> 73 + </body> 74 + </html>`)) 75 + })) 76 + defer server.Close() 77 + 78 + testRule := &articles.ParsingRule{ 79 + Domain: "127.0.0.1", 80 + Title: "//h1[@id='firstHeading']", 81 + Author: "//div[@class='author']", 82 + Date: "//div[@class='date']", 83 + Body: "//div[@id='bodyContent']", 84 + } 85 + helper.AddTestRule("127.0.0.1", testRule) 86 + 87 + err := helper.Add(ctx, server.URL+"/test-article") 88 + Expect.AssertNoError(t, err, "Add should succeed with valid URL") 89 + 90 + articles, err := helper.repos.Articles.List(ctx, &repo.ArticleListOptions{}) 91 + if err != nil { 92 + t.Fatalf("Failed to list articles: %v", err) 93 + } 94 + 95 + if len(articles) != 1 { 96 + t.Errorf("Expected 1 article, got %d", len(articles)) 97 + } 98 + 99 + article := articles[0] 100 + if article.Title != "Test Article Title" { 101 + t.Errorf("Expected title 'Test Article Title', got '%s'", article.Title) 102 + } 103 + if article.Author != "Test Author" { 104 + t.Errorf("Expected author 'Test Author', got '%s'", article.Author) 105 + } 106 + }) 107 + 108 + t.Run("handles duplicate article", func(t *testing.T) { 109 + helper := NewArticleTestHelper(t) 110 + ctx := context.Background() 111 + 112 + duplicateURL := "https://example.com/duplicate" 113 + 114 + existingArticle := &models.Article{ 115 + URL: duplicateURL, 116 + Title: "Existing Article", 117 + Author: "Existing Author", 118 + Date: "2024-01-01", 119 + MarkdownPath: "/path/to/existing.md", 120 + HTMLPath: "/path/to/existing.html", 121 + Created: time.Now(), 122 + Modified: time.Now(), 123 + } 124 + 125 + _, err := helper.repos.Articles.Create(ctx, existingArticle) 126 + if err != nil { 127 + t.Fatalf("Failed to create existing article: %v", err) 128 + } 129 + 130 + err = helper.Add(ctx, duplicateURL) 131 + Expect.AssertNoError(t, err, "Add should succeed with duplicate URL and return existing") 132 + }) 133 + 134 + t.Run("handles unsupported domain", func(t *testing.T) { 135 + helper := NewArticleTestHelper(t) 136 + ctx := context.Background() 137 + 138 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 139 + w.WriteHeader(http.StatusOK) 140 + w.Write([]byte("<html><head><title>Test</title></head><body><p>Content</p></body></html>")) 141 + })) 142 + defer server.Close() 143 + 144 + err := helper.Add(ctx, server.URL+"/unsupported") 145 + Expect.AssertError(t, err, "failed to parse article", "Add should fail with unsupported domain") 146 + }) 147 + 148 + t.Run("handles HTTP error", func(t *testing.T) { 149 + helper := NewArticleTestHelper(t) 150 + ctx := context.Background() 151 + 152 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 153 + w.WriteHeader(http.StatusNotFound) 154 + })) 155 + defer server.Close() 156 + 157 + err := helper.Add(ctx, server.URL+"/404") 158 + Expect.AssertError(t, err, "failed to parse article", "Add should fail with HTTP error") 159 + }) 160 + 161 + t.Run("handles storage directory error", func(t *testing.T) { 162 + helper := NewArticleTestHelper(t) 163 + ctx := context.Background() 164 + 165 + envHelper := NewEnvironmentTestHelper() 166 + defer envHelper.RestoreEnv() 167 + 168 + if runtime.GOOS == "windows" { 169 + envHelper.UnsetEnv("USERPROFILE") 170 + envHelper.UnsetEnv("HOMEDRIVE") 171 + envHelper.UnsetEnv("HOMEPATH") 172 + } else { 173 + envHelper.UnsetEnv("HOME") 174 + } 175 + 176 + err := helper.Add(ctx, "https://example.com/test-article") 177 + Expect.AssertError(t, err, "failed to get article storage dir", "Add should fail when storage directory cannot be determined") 178 + }) 179 + 180 + t.Run("handles database save error", func(t *testing.T) { 181 + helper := NewArticleTestHelper(t) 182 + ctx := context.Background() 183 + 184 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 185 + w.WriteHeader(http.StatusOK) 186 + w.Write([]byte(`<html> 187 + <head><title>Test Article</title></head> 188 + <body> 189 + <h1 id="firstHeading">Test Article</h1> 190 + <div id="bodyContent">Test content</div> 191 + </body> 192 + </html>`)) 193 + })) 194 + defer server.Close() 195 + 196 + testRule := &articles.ParsingRule{ 197 + Domain: "127.0.0.1", 198 + Title: "//h1[@id='firstHeading']", 199 + Body: "//div[@id='bodyContent']", 200 + } 201 + helper.AddTestRule("127.0.0.1", testRule) 202 + 203 + helper.db.Exec("DROP TABLE articles") 204 + 205 + err := helper.Add(ctx, server.URL+"/test") 206 + Expect.AssertError(t, err, "failed to save article to database", "Add should fail when database save fails") 207 + }) 208 + }) 209 + 210 + t.Run("List", func(t *testing.T) { 211 + t.Run("lists all articles", func(t *testing.T) { 212 + helper := NewArticleTestHelper(t) 213 + ctx := context.Background() 214 + 215 + id1 := helper.CreateTestArticle(t, "https://example.com/article1", "First Article", "John Doe", "2024-01-01") 216 + id2 := helper.CreateTestArticle(t, "https://example.com/article2", "Second Article", "Jane Smith", "2024-01-02") 217 + 218 + err := helper.List(ctx, "", "", 0) 219 + Expect.AssertNoError(t, err, "List should succeed") 220 + 221 + Expect.AssertArticleExists(t, helper, id1) 222 + Expect.AssertArticleExists(t, helper, id2) 223 + }) 224 + 225 + t.Run("lists with title filter", func(t *testing.T) { 226 + helper := NewArticleTestHelper(t) 227 + ctx := context.Background() 228 + 229 + helper.CreateTestArticle(t, "https://example.com/first", "First Article", "John", "2024-01-01") 230 + helper.CreateTestArticle(t, "https://example.com/second", "Second Article", "Jane", "2024-01-02") 231 + 232 + err := helper.List(ctx, "First", "", 0) 233 + Expect.AssertNoError(t, err, "List with title filter should succeed") 234 + }) 235 + 236 + t.Run("lists with author filter", func(t *testing.T) { 237 + helper := NewArticleTestHelper(t) 238 + ctx := context.Background() 239 + 240 + helper.CreateTestArticle(t, "https://example.com/john1", "Article by John", "John Doe", "2024-01-01") 241 + helper.CreateTestArticle(t, "https://example.com/jane1", "Article by Jane", "Jane Smith", "2024-01-02") 242 + 243 + err := helper.List(ctx, "", "John", 0) 244 + Expect.AssertNoError(t, err, "List with author filter should succeed") 245 + }) 246 + 247 + t.Run("lists with limit", func(t *testing.T) { 248 + helper := NewArticleTestHelper(t) 249 + ctx := context.Background() 250 + 251 + helper.CreateTestArticle(t, "https://example.com/1", "Article 1", "Author", "2024-01-01") 252 + helper.CreateTestArticle(t, "https://example.com/2", "Article 2", "Author", "2024-01-02") 253 + helper.CreateTestArticle(t, "https://example.com/3", "Article 3", "Author", "2024-01-03") 254 + 255 + err := helper.List(ctx, "", "", 2) 256 + Expect.AssertNoError(t, err, "List with limit should succeed") 257 + }) 258 + 259 + t.Run("handles empty results", func(t *testing.T) { 260 + helper := NewArticleTestHelper(t) 261 + ctx := context.Background() 262 + 263 + err := helper.List(ctx, "nonexistent", "", 0) 264 + Expect.AssertNoError(t, err, "List with no matches should succeed") 265 + }) 266 + 267 + t.Run("handles database error", func(t *testing.T) { 268 + helper := NewArticleTestHelper(t) 269 + ctx := context.Background() 270 + 271 + helper.db.Exec("DROP TABLE articles") 272 + 273 + err := helper.List(ctx, "", "", 0) 274 + Expect.AssertError(t, err, "failed to list articles", "List should fail when database is corrupted") 275 + }) 276 + }) 277 + 278 + t.Run("View", func(t *testing.T) { 279 + t.Run("views article successfully", func(t *testing.T) { 280 + helper := NewArticleTestHelper(t) 281 + ctx := context.Background() 282 + 283 + id := helper.CreateTestArticle(t, "https://example.com/test", "Test Article", "Test Author", "2024-01-01") 284 + 285 + err := helper.View(ctx, id) 286 + Expect.AssertNoError(t, err, "View should succeed with valid article ID") 287 + }) 288 + 289 + t.Run("handles non-existent article", func(t *testing.T) { 290 + helper := NewArticleTestHelper(t) 291 + ctx := context.Background() 292 + 293 + err := helper.View(ctx, 99999) 294 + Expect.AssertError(t, err, "failed to get article", "View should fail with non-existent article ID") 295 + }) 296 + 297 + t.Run("handles missing files gracefully", func(t *testing.T) { 298 + helper := NewArticleTestHelper(t) 299 + ctx := context.Background() 300 + 301 + article := &models.Article{ 302 + URL: "https://example.com/missing-files", 303 + Title: "Missing Files Article", 304 + Author: "Test Author", 305 + Date: "2024-01-01", 306 + MarkdownPath: "/non/existent/path.md", 307 + HTMLPath: "/non/existent/path.html", 308 + Created: time.Now(), 309 + Modified: time.Now(), 310 + } 311 + 312 + id, err := helper.repos.Articles.Create(ctx, article) 313 + if err != nil { 314 + t.Fatalf("Failed to create article with missing files: %v", err) 315 + } 316 + 317 + err = helper.View(ctx, id) 318 + Expect.AssertNoError(t, err, "View should succeed even when files are missing") 319 + }) 320 + 321 + t.Run("handles database error", func(t *testing.T) { 322 + helper := NewArticleTestHelper(t) 323 + ctx := context.Background() 324 + 325 + helper.db.Exec("DROP TABLE articles") 326 + 327 + err := helper.View(ctx, 1) 328 + Expect.AssertError(t, err, "failed to get article", "View should fail when database is corrupted") 329 + }) 330 + }) 331 + 332 + t.Run("Remove", func(t *testing.T) { 333 + t.Run("removes article successfully", func(t *testing.T) { 334 + helper := NewArticleTestHelper(t) 335 + ctx := context.Background() 336 + 337 + id := helper.CreateTestArticle(t, "https://example.com/remove", "Remove Test", "Author", "2024-01-01") 338 + 339 + Expect.AssertArticleExists(t, helper, id) 340 + 341 + err := helper.Remove(ctx, id) 342 + Expect.AssertNoError(t, err, "Remove should succeed") 343 + 344 + Expect.AssertArticleNotExists(t, helper, id) 345 + }) 346 + 347 + t.Run("handles non-existent article", func(t *testing.T) { 348 + helper := NewArticleTestHelper(t) 349 + ctx := context.Background() 350 + 351 + err := helper.Remove(ctx, 99999) 352 + Expect.AssertError(t, err, "failed to get article", "Remove should fail with non-existent article ID") 353 + }) 354 + 355 + t.Run("handles missing files gracefully", func(t *testing.T) { 356 + helper := NewArticleTestHelper(t) 357 + ctx := context.Background() 358 + 359 + article := &models.Article{ 360 + URL: "https://example.com/missing-files", 361 + Title: "Missing Files Article", 362 + Author: "Test Author", 363 + Date: "2024-01-01", 364 + MarkdownPath: "/non/existent/path.md", 365 + HTMLPath: "/non/existent/path.html", 366 + Created: time.Now(), 367 + Modified: time.Now(), 368 + } 369 + 370 + id, err := helper.repos.Articles.Create(ctx, article) 371 + if err != nil { 372 + t.Fatalf("Failed to create article with missing files: %v", err) 373 + } 374 + 375 + err = helper.Remove(ctx, id) 376 + Expect.AssertNoError(t, err, "Remove should succeed even when files don't exist") 377 + }) 378 + 379 + t.Run("handles database error", func(t *testing.T) { 380 + helper := NewArticleTestHelper(t) 381 + ctx := context.Background() 382 + 383 + id := helper.CreateTestArticle(t, "https://example.com/db-error", "DB Error Test", "Author", "2024-01-01") 384 + 385 + helper.db.Exec("DROP TABLE articles") 386 + 387 + err := helper.Remove(ctx, id) 388 + Expect.AssertError(t, err, "failed to get article", "Remove should fail when database is corrupted") 389 + }) 390 + }) 391 + 392 + t.Run("Help", func(t *testing.T) { 393 + t.Run("shows supported domains", func(t *testing.T) { 394 + helper := NewArticleTestHelper(t) 395 + 396 + err := helper.Help() 397 + Expect.AssertNoError(t, err, "Help should succeed") 398 + }) 399 + 400 + t.Run("handles storage directory error", func(t *testing.T) { 401 + helper := NewArticleTestHelper(t) 402 + 403 + envHelper := NewEnvironmentTestHelper() 404 + defer envHelper.RestoreEnv() 405 + 406 + if runtime.GOOS == "windows" { 407 + envHelper.UnsetEnv("USERPROFILE") 408 + envHelper.UnsetEnv("HOMEDRIVE") 409 + envHelper.UnsetEnv("HOMEPATH") 410 + } else { 411 + envHelper.UnsetEnv("HOME") 412 + } 413 + 414 + err := helper.Help() 415 + Expect.AssertError(t, err, "failed to get storage directory", "Help should fail when storage directory cannot be determined") 416 + }) 417 + }) 418 + 419 + t.Run("Close", func(t *testing.T) { 420 + t.Run("closes successfully", func(t *testing.T) { 421 + helper := NewArticleTestHelper(t) 422 + 423 + err := helper.Close() 424 + Expect.AssertNoError(t, err, "Close should succeed") 425 + }) 426 + 427 + t.Run("handles nil database gracefully", func(t *testing.T) { 428 + helper := NewArticleTestHelper(t) 429 + helper.db = nil 430 + 431 + err := helper.Close() 432 + Expect.AssertNoError(t, err, "Close should succeed with nil database") 433 + }) 434 + }) 435 + 436 + t.Run("getStorageDirectory", func(t *testing.T) { 437 + t.Run("returns storage directory successfully", func(t *testing.T) { 438 + helper := NewArticleTestHelper(t) 439 + 440 + dir, err := helper.getStorageDirectory() 441 + Expect.AssertNoError(t, err, "getStorageDirectory should succeed") 442 + 443 + if dir == "" { 444 + t.Error("Storage directory should not be empty") 445 + } 446 + 447 + if !strings.Contains(dir, "Documents/Leaf") { 448 + t.Errorf("Expected storage directory to contain 'Documents/Leaf', got: %s", dir) 449 + } 450 + }) 451 + 452 + t.Run("handles user home directory error", func(t *testing.T) { 453 + helper := NewArticleTestHelper(t) 454 + 455 + envHelper := NewEnvironmentTestHelper() 456 + defer envHelper.RestoreEnv() 457 + 458 + if runtime.GOOS == "windows" { 459 + envHelper.UnsetEnv("USERPROFILE") 460 + envHelper.UnsetEnv("HOMEDRIVE") 461 + envHelper.UnsetEnv("HOMEPATH") 462 + } else { 463 + envHelper.UnsetEnv("HOME") 464 + } 465 + 466 + _, err := helper.getStorageDirectory() 467 + Expect.AssertError(t, err, "", "getStorageDirectory should fail when home directory cannot be determined") 468 + }) 469 + }) 470 + } 471 + 472 + func TestArticleHandlerIntegration(t *testing.T) { 473 + t.Run("end-to-end workflow", func(t *testing.T) { 474 + helper := NewArticleTestHelper(t) 475 + ctx := context.Background() 476 + 477 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 478 + w.WriteHeader(http.StatusOK) 479 + w.Write([]byte(`<html> 480 + <head><title>Integration Test Article</title></head> 481 + <body> 482 + <h1 id="firstHeading">Integration Test Article</h1> 483 + <div class="author">Integration Author</div> 484 + <div id="bodyContent"> 485 + <p>Integration test content.</p> 486 + </div> 487 + </body> 488 + </html>`)) 489 + })) 490 + defer server.Close() 491 + 492 + testRule := &articles.ParsingRule{ 493 + Domain: "127.0.0.1", 494 + Title: "//h1[@id='firstHeading']", 495 + Author: "//div[@class='author']", 496 + Body: "//div[@id='bodyContent']", 497 + } 498 + helper.AddTestRule("127.0.0.1", testRule) 499 + 500 + err := helper.Add(ctx, server.URL+"/integration-test") 501 + Expect.AssertNoError(t, err, "Add should succeed in integration test") 502 + 503 + err = helper.List(ctx, "", "", 0) 504 + Expect.AssertNoError(t, err, "List should succeed in integration test") 505 + 506 + articles, err := helper.repos.Articles.List(ctx, &repo.ArticleListOptions{}) 507 + if err != nil { 508 + t.Fatalf("Failed to get articles for integration test: %v", err) 509 + } 510 + 511 + if len(articles) == 0 { 512 + t.Fatal("Expected at least one article for integration test") 513 + } 514 + 515 + articleID := articles[0].ID 516 + 517 + err = helper.View(ctx, articleID) 518 + Expect.AssertNoError(t, err, "View should succeed in integration test") 519 + 520 + err = helper.Help() 521 + Expect.AssertNoError(t, err, "Help should succeed in integration test") 522 + 523 + err = helper.Remove(ctx, articleID) 524 + Expect.AssertNoError(t, err, "Remove should succeed in integration test") 525 + 526 + Expect.AssertArticleNotExists(t, helper, articleID) 527 + }) 528 + }
+140
internal/handlers/test_utilities.go
··· 8 "testing" 9 "time" 10 11 "github.com/stormlightlabs/noteleaf/internal/models" 12 "github.com/stormlightlabs/noteleaf/internal/store" 13 ) ··· 244 } 245 } 246 247 // EnvironmentTestHelper provides environment manipulation utilities for testing 248 type EnvironmentTestHelper struct { 249 originalVars map[string]string ··· 283 } 284 } 285 286 // Helper function to check if string contains substring (case-insensitive) 287 func containsString(haystack, needle string) bool { 288 if needle == "" { ··· 340 os.Remove(file) 341 } 342 fth.tempFiles = nil 343 } 344 345 var Expect = AssertionHelpers{}
··· 8 "testing" 9 "time" 10 11 + "github.com/stormlightlabs/noteleaf/internal/articles" 12 "github.com/stormlightlabs/noteleaf/internal/models" 13 "github.com/stormlightlabs/noteleaf/internal/store" 14 ) ··· 245 } 246 } 247 248 + // AssertArticleExists checks that an article exists in the database 249 + func (ah AssertionHelpers) AssertArticleExists(t *testing.T, handler *ArticleTestHelper, id int64) { 250 + t.Helper() 251 + ctx := context.Background() 252 + _, err := handler.repos.Articles.Get(ctx, id) 253 + if err != nil { 254 + t.Errorf("Article %d should exist but got error: %v", id, err) 255 + } 256 + } 257 + 258 + // AssertArticleNotExists checks that an article does not exist in the database 259 + func (ah AssertionHelpers) AssertArticleNotExists(t *testing.T, handler *ArticleTestHelper, id int64) { 260 + t.Helper() 261 + ctx := context.Background() 262 + _, err := handler.repos.Articles.Get(ctx, id) 263 + if err == nil { 264 + t.Errorf("Article %d should not exist but was found", id) 265 + } 266 + } 267 + 268 // EnvironmentTestHelper provides environment manipulation utilities for testing 269 type EnvironmentTestHelper struct { 270 originalVars map[string]string ··· 304 } 305 } 306 307 + // CreateTestDir creates a temporary test directory and sets up environment 308 + func (eth *EnvironmentTestHelper) CreateTestDir(t *testing.T) (string, error) { 309 + tempDir, err := os.MkdirTemp("", "noteleaf-test-*") 310 + if err != nil { 311 + return "", err 312 + } 313 + 314 + eth.SetEnv("XDG_CONFIG_HOME", tempDir) 315 + 316 + ctx := context.Background() 317 + err = Setup(ctx, []string{}) 318 + if err != nil { 319 + os.RemoveAll(tempDir) 320 + return "", err 321 + } 322 + 323 + t.Cleanup(func() { 324 + eth.RestoreEnv() 325 + os.RemoveAll(tempDir) 326 + }) 327 + 328 + return tempDir, nil 329 + } 330 + 331 // Helper function to check if string contains substring (case-insensitive) 332 func containsString(haystack, needle string) bool { 333 if needle == "" { ··· 385 os.Remove(file) 386 } 387 fth.tempFiles = nil 388 + } 389 + 390 + // ArticleTestHelper wraps ArticleHandler with test-specific functionality 391 + type ArticleTestHelper struct { 392 + *ArticleHandler 393 + tempDir string 394 + cleanup func() 395 + } 396 + 397 + // NewArticleTestHelper creates an ArticleHandler with isolated test database 398 + func NewArticleTestHelper(t *testing.T) *ArticleTestHelper { 399 + tempDir, err := os.MkdirTemp("", "noteleaf-article-test-*") 400 + if err != nil { 401 + t.Fatalf("Failed to create temp dir: %v", err) 402 + } 403 + 404 + oldConfigHome := os.Getenv("XDG_CONFIG_HOME") 405 + os.Setenv("XDG_CONFIG_HOME", tempDir) 406 + 407 + cleanup := func() { 408 + os.Setenv("XDG_CONFIG_HOME", oldConfigHome) 409 + os.RemoveAll(tempDir) 410 + } 411 + 412 + ctx := context.Background() 413 + err = Setup(ctx, []string{}) 414 + if err != nil { 415 + cleanup() 416 + t.Fatalf("Failed to setup database: %v", err) 417 + } 418 + 419 + handler, err := NewArticleHandler() 420 + if err != nil { 421 + cleanup() 422 + t.Fatalf("Failed to create article handler: %v", err) 423 + } 424 + 425 + testHelper := &ArticleTestHelper{ 426 + ArticleHandler: handler, 427 + tempDir: tempDir, 428 + cleanup: cleanup, 429 + } 430 + 431 + t.Cleanup(func() { 432 + testHelper.Close() 433 + testHelper.cleanup() 434 + }) 435 + 436 + return testHelper 437 + } 438 + 439 + // CreateTestArticle creates a test article and returns its ID 440 + func (ath *ArticleTestHelper) CreateTestArticle(t *testing.T, url, title, author, date string) int64 { 441 + ctx := context.Background() 442 + 443 + mdPath := filepath.Join(ath.tempDir, fmt.Sprintf("%s.md", title)) 444 + htmlPath := filepath.Join(ath.tempDir, fmt.Sprintf("%s.html", title)) 445 + 446 + mdContent := fmt.Sprintf("# %s\n\n**Author:** %s\n**Date:** %s\n\nTest content", title, author, date) 447 + err := os.WriteFile(mdPath, []byte(mdContent), 0644) 448 + if err != nil { 449 + t.Fatalf("Failed to create test markdown file: %v", err) 450 + } 451 + 452 + htmlContent := fmt.Sprintf("<h1>%s</h1><p>Author: %s</p><p>Date: %s</p><p>Test content</p>", title, author, date) 453 + err = os.WriteFile(htmlPath, []byte(htmlContent), 0644) 454 + if err != nil { 455 + t.Fatalf("Failed to create test HTML file: %v", err) 456 + } 457 + 458 + article := &models.Article{ 459 + URL: url, 460 + Title: title, 461 + Author: author, 462 + Date: date, 463 + MarkdownPath: mdPath, 464 + HTMLPath: htmlPath, 465 + Created: time.Now(), 466 + Modified: time.Now(), 467 + } 468 + 469 + id, err := ath.repos.Articles.Create(ctx, article) 470 + if err != nil { 471 + t.Fatalf("Failed to create test article: %v", err) 472 + } 473 + return id 474 + } 475 + 476 + // AddTestRule adds a parsing rule to the handler's parser for testing 477 + func (ath *ArticleTestHelper) AddTestRule(domain string, rule *articles.ParsingRule) { 478 + if parser, ok := ath.parser.(*articles.ArticleParser); ok { 479 + parser.AddRule(domain, rule) 480 + } else { 481 + panic("Could not cast parser to ArticleParser") 482 + } 483 } 484 485 var Expect = AssertionHelpers{}