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

feat: article migrations & model code

+219 -619
+2
go.mod
··· 15 15 golang.org/x/time v0.12.0 16 16 ) 17 17 18 + require github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a // indirect 19 + 18 20 require ( 19 21 github.com/PuerkitoBio/goquery v1.10.3 20 22 github.com/andybalholm/cascadia v1.3.3 // indirect
+2
go.sum
··· 76 76 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 77 77 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 78 78 github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 79 + github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A= 80 + github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= 79 81 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 80 82 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 81 83 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+38
internal/models/models.go
··· 2 2 3 3 import ( 4 4 "encoding/json" 5 + "net/url" 5 6 "slices" 6 7 "time" 7 8 ) ··· 156 157 Description string `json:"description,omitempty"` 157 158 Created time.Time `json:"created"` 158 159 Modified time.Time `json:"modified"` 160 + } 161 + 162 + // Article represents a parsed article from a web URL 163 + type Article struct { 164 + ID int64 `json:"id"` 165 + URL string `json:"url"` 166 + Title string `json:"title"` 167 + Author string `json:"author,omitempty"` 168 + Date string `json:"date,omitempty"` 169 + MarkdownPath string `json:"markdown_path"` 170 + HTMLPath string `json:"html_path"` 171 + Created time.Time `json:"created"` 172 + Modified time.Time `json:"modified"` 159 173 } 160 174 161 175 // MarshalTags converts tags slice to JSON string for database storage ··· 468 482 func (te *TimeEntry) SetCreatedAt(time time.Time) { te.Created = time } 469 483 func (te *TimeEntry) GetUpdatedAt() time.Time { return te.Modified } 470 484 func (te *TimeEntry) SetUpdatedAt(time time.Time) { te.Modified = time } 485 + 486 + func (a *Article) GetID() int64 { return a.ID } 487 + func (a *Article) SetID(id int64) { a.ID = id } 488 + func (a *Article) GetTableName() string { return "articles" } 489 + func (a *Article) GetCreatedAt() time.Time { return a.Created } 490 + func (a *Article) SetCreatedAt(time time.Time) { a.Created = time } 491 + func (a *Article) GetUpdatedAt() time.Time { return a.Modified } 492 + func (a *Article) SetUpdatedAt(time time.Time) { a.Modified = time } 493 + 494 + // IsValidURL returns true if the article has parseable URL 495 + func (a *Article) IsValidURL() bool { 496 + _, err := url.ParseRequestURI(a.URL) 497 + return err == nil 498 + } 499 + 500 + // HasAuthor returns true if the article has an author 501 + func (a *Article) HasAuthor() bool { 502 + return a.Author != "" 503 + } 504 + 505 + // HasDate returns true if the article has a date 506 + func (a *Article) HasDate() bool { 507 + return a.Date != "" 508 + }
+157 -619
internal/models/models_test.go
··· 2 2 3 3 import ( 4 4 "encoding/json" 5 + "fmt" 5 6 "testing" 6 7 "time" 7 8 ) 8 9 9 10 func TestModels(t *testing.T) { 10 - t.Run("Task Model", func(t *testing.T) { 11 - t.Run("Model Interface Implementation", func(t *testing.T) { 12 - task := &Task{ 13 - ID: 1, 14 - UUID: "test-uuid", 15 - Description: "Test task", 16 - Status: "pending", 17 - Entry: time.Now(), 18 - Modified: time.Now(), 19 - } 11 + t.Run("Model Interface", func(t *testing.T) { 12 + now := time.Now() 13 + time.Sleep(time.Duration(500) * time.Duration(time.Millisecond)) 14 + updated := time.Now() 15 + 16 + for i, tc := range []struct { 17 + name string 18 + model Model 19 + unmarshaled Model 20 + }{ 21 + {name: "Task", model: &Task{ID: 1, Entry: now, Modified: updated}, unmarshaled: &Task{}}, 22 + {name: "Movie", model: &Movie{ID: 1, Title: "Test Movie", Year: 2023, Added: now}, unmarshaled: &Movie{}}, 23 + {name: "TVShow", model: &TVShow{ID: 1, Title: "Test Show", Added: now}, unmarshaled: &TVShow{}}, 24 + {name: "Book", model: &Book{ID: 1, Title: "Test Book", Added: now}, unmarshaled: &Book{}}, 25 + {name: "Note", model: &Note{ID: 1, Title: "Test Note", Content: "This is test content", Created: now}, unmarshaled: &Note{}}, 26 + {name: "Album", model: &Album{ID: 1, Title: "Test Album", Artist: "Test Artist", Created: now}, unmarshaled: &Album{}}, 27 + {name: "TimeEntry", model: &TimeEntry{ID: 1, TaskID: 100, Created: now, Modified: updated}, unmarshaled: &TimeEntry{}}, 28 + {name: "Article", model: &Article{ID: 1, Created: now, Modified: updated}, unmarshaled: &Article{}}, 29 + } { 30 + model := tc.model 31 + t.Run(fmt.Sprintf("%v Implementation", tc.name), func(t *testing.T) { 32 + model.SetID(int64(i + 1)) 33 + if model.GetID() != int64(i+1) { 34 + t.Errorf("Model %d: ID not set correctly", i) 35 + } 36 + 37 + tableName := model.GetTableName() 38 + if tableName == "" { 39 + t.Errorf("Model %d: table name should not be empty", i) 40 + } 41 + 42 + now = time.Now() 43 + model.SetCreatedAt(now) 44 + // NOTE: We don't test exact equality due to potential precision differences 45 + if model.GetCreatedAt().IsZero() { 46 + t.Errorf("Model %d: created at should not be zero", i) 47 + } 20 48 21 - if task.GetID() != 1 { 22 - t.Errorf("Expected ID 1, got %d", task.GetID()) 23 - } 49 + updatedAt := time.Now().Add(time.Hour) 50 + model.SetUpdatedAt(updatedAt) 51 + if !model.GetUpdatedAt().Equal(updatedAt) { 52 + t.Errorf("Expected updated at %v, got %v", updatedAt, model.GetUpdatedAt()) 53 + } 24 54 25 - task.SetID(2) 26 - if task.GetID() != 2 { 27 - t.Errorf("Expected ID 2 after SetID, got %d", task.GetID()) 28 - } 55 + if model.GetUpdatedAt().IsZero() { 56 + t.Errorf("Model %d: updated at should not be zero", i) 57 + } 58 + model.SetUpdatedAt(now) 29 59 30 - if task.GetTableName() != "tasks" { 31 - t.Errorf("Expected table name 'tasks', got '%s'", task.GetTableName()) 32 - } 60 + t.Run(fmt.Sprintf("%v JSON Marshal/Unmarshal", tc.name), func(t *testing.T) { 61 + if data, err := json.Marshal(model); err != nil { 62 + t.Fatalf("JSON marshal failed: %v", err) 63 + } else { 64 + var unmarshaled = tc.unmarshaled 65 + if err = json.Unmarshal(data, &unmarshaled); err != nil { 66 + t.Fatalf("JSON unmarshal failed: %v", err) 67 + } 33 68 34 - createdAt := time.Now() 35 - task.SetCreatedAt(createdAt) 36 - if !task.GetCreatedAt().Equal(createdAt) { 37 - t.Errorf("Expected created at %v, got %v", createdAt, task.GetCreatedAt()) 38 - } 69 + if unmarshaled.GetID() != model.GetID() { 70 + t.Fatalf("IDs should be the same") 71 + } 72 + } 73 + }) 74 + }) 75 + } 39 76 40 - updatedAt := time.Now().Add(time.Hour) 41 - task.SetUpdatedAt(updatedAt) 42 - if !task.GetUpdatedAt().Equal(updatedAt) { 43 - t.Errorf("Expected updated at %v, got %v", updatedAt, task.GetUpdatedAt()) 44 - } 45 - }) 77 + }) 46 78 79 + t.Run("Task Model", func(t *testing.T) { 47 80 t.Run("Status Methods", func(t *testing.T) { 48 81 testCases := []struct { 49 82 status string ··· 341 374 } 342 375 }) 343 376 344 - t.Run("JSON Marshaling", func(t *testing.T) { 345 - now := time.Now() 346 - due := now.Add(24 * time.Hour) 347 - task := &Task{ 348 - ID: 1, 349 - UUID: "test-uuid", 350 - Description: "Test task", 351 - Status: "pending", 352 - Priority: "A", 353 - Project: "test-project", 354 - Tags: []string{"work", "urgent"}, 355 - Due: &due, 356 - Entry: now, 357 - Modified: now, 358 - Annotations: []string{"Note 1"}, 359 - } 360 - 361 - data, err := json.Marshal(task) 362 - if err != nil { 363 - t.Fatalf("JSON marshal failed: %v", err) 364 - } 365 - 366 - var unmarshaled Task 367 - err = json.Unmarshal(data, &unmarshaled) 368 - if err != nil { 369 - t.Fatalf("JSON unmarshal failed: %v", err) 370 - } 371 - 372 - if unmarshaled.ID != task.ID { 373 - t.Errorf("Expected ID %d, got %d", task.ID, unmarshaled.ID) 374 - } 375 - if unmarshaled.UUID != task.UUID { 376 - t.Errorf("Expected UUID %s, got %s", task.UUID, unmarshaled.UUID) 377 - } 378 - if unmarshaled.Description != task.Description { 379 - t.Errorf("Expected description %s, got %s", task.Description, unmarshaled.Description) 380 - } 381 - }) 382 377 }) 383 378 384 379 t.Run("Movie Model", func(t *testing.T) { 385 - t.Run("Model Interface Implementation", func(t *testing.T) { 386 - movie := &Movie{ 387 - ID: 1, 388 - Title: "Test Movie", 389 - Year: 2023, 390 - Added: time.Now(), 391 - } 392 - 393 - if movie.GetID() != 1 { 394 - t.Errorf("Expected ID 1, got %d", movie.GetID()) 395 - } 396 - 397 - movie.SetID(2) 398 - if movie.GetID() != 2 { 399 - t.Errorf("Expected ID 2 after SetID, got %d", movie.GetID()) 400 - } 401 - 402 - if movie.GetTableName() != "movies" { 403 - t.Errorf("Expected table name 'movies', got '%s'", movie.GetTableName()) 404 - } 405 - 406 - createdAt := time.Now() 407 - movie.SetCreatedAt(createdAt) 408 - if !movie.GetCreatedAt().Equal(createdAt) { 409 - t.Errorf("Expected created at %v, got %v", createdAt, movie.GetCreatedAt()) 410 - } 411 - 412 - updatedAt := time.Now().Add(time.Hour) 413 - movie.SetUpdatedAt(updatedAt) 414 - if !movie.GetUpdatedAt().Equal(updatedAt) { 415 - t.Errorf("Expected updated at %v, got %v", updatedAt, movie.GetUpdatedAt()) 416 - } 417 - }) 418 - 419 380 t.Run("Status Methods", func(t *testing.T) { 420 381 testCases := []struct { 421 382 status string ··· 439 400 } 440 401 } 441 402 }) 442 - 443 - t.Run("JSON Marshaling", func(t *testing.T) { 444 - now := time.Now() 445 - watched := now.Add(-24 * time.Hour) 446 - movie := &Movie{ 447 - ID: 1, 448 - Title: "Test Movie", 449 - Year: 2023, 450 - Status: "watched", 451 - Rating: 8.5, 452 - Notes: "Great movie!", 453 - Added: now, 454 - Watched: &watched, 455 - } 456 - 457 - data, err := json.Marshal(movie) 458 - if err != nil { 459 - t.Fatalf("JSON marshal failed: %v", err) 460 - } 461 - 462 - var unmarshaled Movie 463 - err = json.Unmarshal(data, &unmarshaled) 464 - if err != nil { 465 - t.Fatalf("JSON unmarshal failed: %v", err) 466 - } 467 - 468 - if unmarshaled.ID != movie.ID { 469 - t.Errorf("Expected ID %d, got %d", movie.ID, unmarshaled.ID) 470 - } 471 - if unmarshaled.Title != movie.Title { 472 - t.Errorf("Expected title %s, got %s", movie.Title, unmarshaled.Title) 473 - } 474 - if unmarshaled.Rating != movie.Rating { 475 - t.Errorf("Expected rating %f, got %f", movie.Rating, unmarshaled.Rating) 476 - } 477 - }) 478 403 }) 479 404 480 405 t.Run("TV Show Model", func(t *testing.T) { 481 - t.Run("Model Interface Implementation", func(t *testing.T) { 482 - tvShow := &TVShow{ 483 - ID: 1, 484 - Title: "Test Show", 485 - Added: time.Now(), 486 - } 487 - 488 - if tvShow.GetID() != 1 { 489 - t.Errorf("Expected ID 1, got %d", tvShow.GetID()) 490 - } 491 - 492 - tvShow.SetID(2) 493 - if tvShow.GetID() != 2 { 494 - t.Errorf("Expected ID 2 after SetID, got %d", tvShow.GetID()) 495 - } 496 - 497 - if tvShow.GetTableName() != "tv_shows" { 498 - t.Errorf("Expected table name 'tv_shows', got '%s'", tvShow.GetTableName()) 499 - } 500 - 501 - createdAt := time.Now() 502 - tvShow.SetCreatedAt(createdAt) 503 - if !tvShow.GetCreatedAt().Equal(createdAt) { 504 - t.Errorf("Expected created at %v, got %v", createdAt, tvShow.GetCreatedAt()) 505 - } 506 - 507 - updatedAt := time.Now().Add(time.Hour) 508 - tvShow.SetUpdatedAt(updatedAt) 509 - if !tvShow.GetUpdatedAt().Equal(updatedAt) { 510 - t.Errorf("Expected updated at %v, got %v", updatedAt, tvShow.GetUpdatedAt()) 511 - } 512 - }) 513 - 514 406 t.Run("Status Methods", func(t *testing.T) { 515 407 testCases := []struct { 516 408 status string ··· 539 431 } 540 432 } 541 433 }) 542 - 543 - t.Run("JSON Marshaling", func(t *testing.T) { 544 - now := time.Now() 545 - lastWatched := now.Add(-24 * time.Hour) 546 - tvShow := &TVShow{ 547 - ID: 1, 548 - Title: "Test Show", 549 - Season: 1, 550 - Episode: 5, 551 - Status: "watching", 552 - Rating: 9.0, 553 - Notes: "Amazing series!", 554 - Added: now, 555 - LastWatched: &lastWatched, 556 - } 557 - 558 - data, err := json.Marshal(tvShow) 559 - if err != nil { 560 - t.Fatalf("JSON marshal failed: %v", err) 561 - } 562 - 563 - var unmarshaled TVShow 564 - err = json.Unmarshal(data, &unmarshaled) 565 - if err != nil { 566 - t.Fatalf("JSON unmarshal failed: %v", err) 567 - } 568 - 569 - if unmarshaled.ID != tvShow.ID { 570 - t.Errorf("Expected ID %d, got %d", tvShow.ID, unmarshaled.ID) 571 - } 572 - if unmarshaled.Title != tvShow.Title { 573 - t.Errorf("Expected title %s, got %s", tvShow.Title, unmarshaled.Title) 574 - } 575 - if unmarshaled.Season != tvShow.Season { 576 - t.Errorf("Expected season %d, got %d", tvShow.Season, unmarshaled.Season) 577 - } 578 - if unmarshaled.Episode != tvShow.Episode { 579 - t.Errorf("Expected episode %d, got %d", tvShow.Episode, unmarshaled.Episode) 580 - } 581 - }) 582 434 }) 583 435 584 436 t.Run("Book Model", func(t *testing.T) { 585 - t.Run("Model Interface Implementation", func(t *testing.T) { 586 - book := &Book{ 587 - ID: 1, 588 - Title: "Test Book", 589 - Added: time.Now(), 590 - } 591 - 592 - if book.GetID() != 1 { 593 - t.Errorf("Expected ID 1, got %d", book.GetID()) 594 - } 595 - 596 - book.SetID(2) 597 - if book.GetID() != 2 { 598 - t.Errorf("Expected ID 2 after SetID, got %d", book.GetID()) 599 - } 600 - 601 - if book.GetTableName() != "books" { 602 - t.Errorf("Expected table name 'books', got '%s'", book.GetTableName()) 603 - } 604 - 605 - createdAt := time.Now() 606 - book.SetCreatedAt(createdAt) 607 - if !book.GetCreatedAt().Equal(createdAt) { 608 - t.Errorf("Expected created at %v, got %v", createdAt, book.GetCreatedAt()) 609 - } 610 - 611 - updatedAt := time.Now().Add(time.Hour) 612 - book.SetUpdatedAt(updatedAt) 613 - if !book.GetUpdatedAt().Equal(updatedAt) { 614 - t.Errorf("Expected updated at %v, got %v", updatedAt, book.GetUpdatedAt()) 615 - } 616 - }) 617 - 618 437 t.Run("Status Methods", func(t *testing.T) { 619 438 testCases := []struct { 620 439 status string ··· 651 470 t.Errorf("Expected progress 75%%, got %d%%", book.ProgressPercent()) 652 471 } 653 472 }) 654 - 655 - t.Run("JSON Marshaling", func(t *testing.T) { 656 - now := time.Now() 657 - started := now.Add(-7 * 24 * time.Hour) 658 - finished := now.Add(-24 * time.Hour) 659 - book := &Book{ 660 - ID: 1, 661 - Title: "Test Book", 662 - Author: "Test Author", 663 - Status: "finished", 664 - Progress: 100, 665 - Pages: 300, 666 - Rating: 4.5, 667 - Notes: "Excellent read!", 668 - Added: now, 669 - Started: &started, 670 - Finished: &finished, 671 - } 672 - 673 - data, err := json.Marshal(book) 674 - if err != nil { 675 - t.Fatalf("JSON marshal failed: %v", err) 676 - } 677 - 678 - var unmarshaled Book 679 - err = json.Unmarshal(data, &unmarshaled) 680 - if err != nil { 681 - t.Fatalf("JSON unmarshal failed: %v", err) 682 - } 683 - 684 - if unmarshaled.ID != book.ID { 685 - t.Errorf("Expected ID %d, got %d", book.ID, unmarshaled.ID) 686 - } 687 - if unmarshaled.Title != book.Title { 688 - t.Errorf("Expected title %s, got %s", book.Title, unmarshaled.Title) 689 - } 690 - if unmarshaled.Author != book.Author { 691 - t.Errorf("Expected author %s, got %s", book.Author, unmarshaled.Author) 692 - } 693 - if unmarshaled.Progress != book.Progress { 694 - t.Errorf("Expected progress %d, got %d", book.Progress, unmarshaled.Progress) 695 - } 696 - if unmarshaled.Pages != book.Pages { 697 - t.Errorf("Expected pages %d, got %d", book.Pages, unmarshaled.Pages) 698 - } 699 - }) 700 473 }) 701 474 702 475 t.Run("Note Model", func(t *testing.T) { 703 - t.Run("Model Interface Implementation", func(t *testing.T) { 704 - note := &Note{ 705 - ID: 1, 706 - Title: "Test Note", 707 - Content: "This is test content", 708 - Created: time.Now(), 709 - } 710 - 711 - if note.GetID() != 1 { 712 - t.Errorf("Expected ID 1, got %d", note.GetID()) 713 - } 714 - 715 - note.SetID(2) 716 - if note.GetID() != 2 { 717 - t.Errorf("Expected ID 2 after SetID, got %d", note.GetID()) 718 - } 719 - 720 - if note.GetTableName() != "notes" { 721 - t.Errorf("Expected table name 'notes', got '%s'", note.GetTableName()) 722 - } 723 - 724 - createdAt := time.Now() 725 - note.SetCreatedAt(createdAt) 726 - if !note.GetCreatedAt().Equal(createdAt) { 727 - t.Errorf("Expected created at %v, got %v", createdAt, note.GetCreatedAt()) 728 - } 729 - 730 - updatedAt := time.Now().Add(time.Hour) 731 - note.SetUpdatedAt(updatedAt) 732 - if !note.GetUpdatedAt().Equal(updatedAt) { 733 - t.Errorf("Expected updated at %v, got %v", updatedAt, note.GetUpdatedAt()) 734 - } 735 - }) 736 - 737 476 t.Run("Archive Methods", func(t *testing.T) { 738 477 note := &Note{Archived: false} 739 478 ··· 791 530 t.Error("Expected nil tags for empty string") 792 531 } 793 532 }) 794 - 795 - t.Run("JSON Marshaling", func(t *testing.T) { 796 - now := time.Now() 797 - modified := now.Add(time.Hour) 798 - note := &Note{ 799 - ID: 1, 800 - Title: "Test Note", 801 - Content: "This is test content with **markdown**", 802 - Tags: []string{"personal", "markdown"}, 803 - Archived: false, 804 - Created: now, 805 - Modified: modified, 806 - FilePath: "/path/to/note.md", 807 - } 808 - 809 - data, err := json.Marshal(note) 810 - if err != nil { 811 - t.Fatalf("JSON marshal failed: %v", err) 812 - } 813 - 814 - var unmarshaled Note 815 - err = json.Unmarshal(data, &unmarshaled) 816 - if err != nil { 817 - t.Fatalf("JSON unmarshal failed: %v", err) 818 - } 819 - 820 - if unmarshaled.ID != note.ID { 821 - t.Errorf("Expected ID %d, got %d", note.ID, unmarshaled.ID) 822 - } 823 - if unmarshaled.Title != note.Title { 824 - t.Errorf("Expected title %s, got %s", note.Title, unmarshaled.Title) 825 - } 826 - if unmarshaled.Content != note.Content { 827 - t.Errorf("Expected content %s, got %s", note.Content, unmarshaled.Content) 828 - } 829 - if unmarshaled.Archived != note.Archived { 830 - t.Errorf("Expected archived %v, got %v", note.Archived, unmarshaled.Archived) 831 - } 832 - if unmarshaled.FilePath != note.FilePath { 833 - t.Errorf("Expected file path %s, got %s", note.FilePath, unmarshaled.FilePath) 834 - } 835 - }) 836 533 }) 837 534 838 535 t.Run("Album Model", func(t *testing.T) { 839 - t.Run("Model Interface Implementation", func(t *testing.T) { 840 - album := &Album{ 841 - ID: 1, 842 - Title: "Test Album", 843 - Artist: "Test Artist", 844 - Created: time.Now(), 845 - } 846 - 847 - if album.GetID() != 1 { 848 - t.Errorf("Expected ID 1, got %d", album.GetID()) 849 - } 850 - 851 - album.SetID(2) 852 - if album.GetID() != 2 { 853 - t.Errorf("Expected ID 2 after SetID, got %d", album.GetID()) 854 - } 855 - 856 - if album.GetTableName() != "albums" { 857 - t.Errorf("Expected table name 'albums', got '%s'", album.GetTableName()) 858 - } 859 - 860 - createdAt := time.Now() 861 - album.SetCreatedAt(createdAt) 862 - if !album.GetCreatedAt().Equal(createdAt) { 863 - t.Errorf("Expected created at %v, got %v", createdAt, album.GetCreatedAt()) 864 - } 865 - 866 - updatedAt := time.Now().Add(time.Hour) 867 - album.SetUpdatedAt(updatedAt) 868 - if !album.GetUpdatedAt().Equal(updatedAt) { 869 - t.Errorf("Expected updated at %v, got %v", updatedAt, album.GetUpdatedAt()) 870 - } 871 - }) 872 - 873 536 t.Run("Rating Methods", func(t *testing.T) { 874 537 album := &Album{} 875 538 ··· 890 553 t.Error("Album with valid rating should return true for IsValidRating") 891 554 } 892 555 893 - testCases := []struct { 556 + for _, tc := range []struct { 894 557 rating int 895 558 isValid bool 896 - }{ 897 - {0, false}, 898 - {1, true}, 899 - {3, true}, 900 - {5, true}, 901 - {6, false}, 902 - {-1, false}, 903 - } 904 - 905 - for _, tc := range testCases { 559 + }{{0, false}, {1, true}, {3, true}, {5, true}, {6, false}, {-1, false}} { 906 560 album.Rating = tc.rating 907 561 if album.IsValidRating() != tc.isValid { 908 562 t.Errorf("Rating %d: expected IsValidRating %v, got %v", tc.rating, tc.isValid, album.IsValidRating()) ··· 913 567 t.Run("Tracks Marshaling", func(t *testing.T) { 914 568 album := &Album{} 915 569 916 - result, err := album.MarshalTracks() 917 - if err != nil { 570 + if result, err := album.MarshalTracks(); err != nil { 918 571 t.Fatalf("MarshalTracks failed: %v", err) 919 - } 920 - if result != "" { 921 - t.Errorf("Expected empty string for empty tracks, got '%s'", result) 572 + } else { 573 + if result != "" { 574 + t.Errorf("Expected empty string for empty tracks, got '%s'", result) 575 + } 922 576 } 923 577 924 578 album.Tracks = []string{"Track 1", "Track 2", "Interlude"} 925 - result, err = album.MarshalTracks() 579 + result, err := album.MarshalTracks() 926 580 if err != nil { 927 581 t.Fatalf("MarshalTracks failed: %v", err) 928 582 } 929 583 930 - expected := `["Track 1","Track 2","Interlude"]` 931 - if result != expected { 584 + if expected := `["Track 1","Track 2","Interlude"]`; result != expected { 932 585 t.Errorf("Expected %s, got %s", expected, result) 933 586 } 934 587 935 588 newAlbum := &Album{} 936 - err = newAlbum.UnmarshalTracks(result) 937 - if err != nil { 589 + if err = newAlbum.UnmarshalTracks(result); err != nil { 938 590 t.Fatalf("UnmarshalTracks failed: %v", err) 939 - } 591 + } else { 592 + if len(newAlbum.Tracks) != 3 { 593 + t.Errorf("Expected 3 tracks, got %d", len(newAlbum.Tracks)) 594 + } 940 595 941 - if len(newAlbum.Tracks) != 3 { 942 - t.Errorf("Expected 3 tracks, got %d", len(newAlbum.Tracks)) 943 - } 944 - if newAlbum.Tracks[0] != "Track 1" || newAlbum.Tracks[1] != "Track 2" || newAlbum.Tracks[2] != "Interlude" { 945 - t.Errorf("Tracks not unmarshaled correctly: %v", newAlbum.Tracks) 596 + if newAlbum.Tracks[0] != "Track 1" || newAlbum.Tracks[1] != "Track 2" || newAlbum.Tracks[2] != "Interlude" { 597 + t.Errorf("Tracks not unmarshaled correctly: %v", newAlbum.Tracks) 598 + } 946 599 } 947 600 948 601 emptyAlbum := &Album{} 949 - err = emptyAlbum.UnmarshalTracks("") 950 - if err != nil { 602 + if err = emptyAlbum.UnmarshalTracks(""); err != nil { 951 603 t.Fatalf("UnmarshalTracks with empty string failed: %v", err) 952 - } 953 - if emptyAlbum.Tracks != nil { 604 + } else if emptyAlbum.Tracks != nil { 954 605 t.Error("Expected nil tracks for empty string") 955 606 } 956 607 }) 957 - 958 - t.Run("JSON Marshaling", func(t *testing.T) { 959 - now := time.Now() 960 - modified := now.Add(time.Hour) 961 - album := &Album{ 962 - ID: 1, 963 - Title: "Test Album", 964 - Artist: "Test Artist", 965 - Genre: "Rock", 966 - ReleaseYear: 2023, 967 - Tracks: []string{"Track 1", "Track 2"}, 968 - DurationSeconds: 3600, 969 - AlbumArtPath: "/path/to/art.jpg", 970 - Rating: 4, 971 - Created: now, 972 - Modified: modified, 973 - } 974 - 975 - data, err := json.Marshal(album) 976 - if err != nil { 977 - t.Fatalf("JSON marshal failed: %v", err) 978 - } 979 - 980 - var unmarshaled Album 981 - err = json.Unmarshal(data, &unmarshaled) 982 - if err != nil { 983 - t.Fatalf("JSON unmarshal failed: %v", err) 984 - } 985 - 986 - if unmarshaled.ID != album.ID { 987 - t.Errorf("Expected ID %d, got %d", album.ID, unmarshaled.ID) 988 - } 989 - if unmarshaled.Title != album.Title { 990 - t.Errorf("Expected title %s, got %s", album.Title, unmarshaled.Title) 991 - } 992 - if unmarshaled.Artist != album.Artist { 993 - t.Errorf("Expected artist %s, got %s", album.Artist, unmarshaled.Artist) 994 - } 995 - if unmarshaled.Genre != album.Genre { 996 - t.Errorf("Expected genre %s, got %s", album.Genre, unmarshaled.Genre) 997 - } 998 - if unmarshaled.ReleaseYear != album.ReleaseYear { 999 - t.Errorf("Expected release year %d, got %d", album.ReleaseYear, unmarshaled.ReleaseYear) 1000 - } 1001 - if unmarshaled.DurationSeconds != album.DurationSeconds { 1002 - t.Errorf("Expected duration %d, got %d", album.DurationSeconds, unmarshaled.DurationSeconds) 1003 - } 1004 - if unmarshaled.Rating != album.Rating { 1005 - t.Errorf("Expected rating %d, got %d", album.Rating, unmarshaled.Rating) 1006 - } 1007 - }) 1008 608 }) 1009 609 1010 - t.Run("Interface Implementations", func(t *testing.T) { 1011 - t.Run("All models implement Model interface", func(t *testing.T) { 1012 - var models []Model 1013 - 1014 - task := &Task{} 1015 - movie := &Movie{} 1016 - tvShow := &TVShow{} 1017 - book := &Book{} 1018 - note := &Note{} 1019 - album := &Album{} 1020 - 1021 - models = append(models, task, movie, tvShow, book, note, album) 1022 - 1023 - if len(models) != 6 { 1024 - t.Errorf("Expected 6 models, got %d", len(models)) 1025 - } 610 + t.Run("Article Model", func(t *testing.T) { 611 + article := Article{URL: "", Author: "", Date: ""} 612 + want := false 1026 613 1027 - for i, model := range models { 1028 - model.SetID(int64(i + 1)) 1029 - if model.GetID() != int64(i+1) { 1030 - t.Errorf("Model %d: ID not set correctly", i) 1031 - } 1032 - 1033 - tableName := model.GetTableName() 1034 - if tableName == "" { 1035 - t.Errorf("Model %d: table name should not be empty", i) 1036 - } 1037 - 1038 - now := time.Now() 1039 - model.SetCreatedAt(now) 1040 - model.SetUpdatedAt(now) 1041 - 1042 - // NOTE: We don't test exact equality due to potential precision differences 1043 - if model.GetCreatedAt().IsZero() { 1044 - t.Errorf("Model %d: created at should not be zero", i) 1045 - } 1046 - if model.GetUpdatedAt().IsZero() { 1047 - t.Errorf("Model %d: updated at should not be zero", i) 1048 - } 1049 - } 1050 - }) 1051 - }) 1052 - 1053 - t.Run("Errors & Edge cases", func(t *testing.T) { 1054 - t.Run("Marshaling Errors", func(t *testing.T) { 1055 - t.Run("UnmarshalTags handles invalid JSON", func(t *testing.T) { 1056 - task := &Task{} 1057 - err := task.UnmarshalTags(`{"invalid": "json"}`) 1058 - if err == nil { 1059 - t.Error("Expected error for invalid JSON, got nil") 1060 - } 1061 - }) 1062 - 1063 - t.Run("UnmarshalAnnotations handles invalid JSON", func(t *testing.T) { 1064 - task := &Task{} 1065 - err := task.UnmarshalAnnotations(`{"invalid": "json"}`) 1066 - if err == nil { 1067 - t.Error("Expected error for invalid JSON, got nil") 1068 - } 1069 - }) 1070 - }) 1071 - }) 1072 - 1073 - t.Run("Edge Cases", func(t *testing.T) { 1074 - t.Run("Task with nil slices", func(t *testing.T) { 1075 - task := &Task{ 1076 - Tags: nil, 1077 - Annotations: nil, 1078 - } 1079 - 1080 - tagsJSON, err := task.MarshalTags() 1081 - if err != nil { 1082 - t.Errorf("MarshalTags with nil slice failed: %v", err) 1083 - } 1084 - if tagsJSON != "" { 1085 - t.Errorf("Expected empty string for nil tags, got '%s'", tagsJSON) 1086 - } 1087 - 1088 - annotationsJSON, err := task.MarshalAnnotations() 1089 - if err != nil { 1090 - t.Errorf("MarshalAnnotations with nil slice failed: %v", err) 1091 - } 1092 - if annotationsJSON != "" { 1093 - t.Errorf("Expected empty string for nil annotations, got '%s'", annotationsJSON) 1094 - } 1095 - }) 1096 - 1097 - t.Run("Models with zero values", func(t *testing.T) { 1098 - task := &Task{} 1099 - movie := &Movie{} 1100 - tvShow := &TVShow{} 1101 - book := &Book{} 1102 - note := &Note{} 1103 - 1104 - if task.IsCompleted() || task.IsPending() || task.IsDeleted() { 1105 - t.Error("Zero value task should have false status methods") 1106 - } 1107 - 1108 - if movie.IsWatched() || movie.IsQueued() { 1109 - t.Error("Zero value movie should have false status methods") 1110 - } 1111 - 1112 - if tvShow.IsWatching() || tvShow.IsWatched() || tvShow.IsQueued() { 1113 - t.Error("Zero value TV show should have false status methods") 1114 - } 1115 - 1116 - if book.IsReading() || book.IsFinished() || book.IsQueued() { 1117 - t.Error("Zero value book should have false status methods") 614 + for _, tc := range []func() bool{article.HasAuthor, article.HasDate, article.IsValidURL} { 615 + got := tc() 616 + if got != want { 617 + t.Errorf("wanted %v, got %v", want, got) 1118 618 } 619 + } 1119 620 1120 - if book.ProgressPercent() != 0 { 1121 - t.Errorf("Zero value book should have 0%% progress, got %d%%", book.ProgressPercent()) 1122 - } 621 + article.URL = "http//wikipedia.org" 622 + if article.IsValidURL() != want { 623 + t.Errorf("%v is invalid but got valid", article.URL) 624 + } 1123 625 1124 - if note.IsArchived() { 1125 - t.Error("Zero value note should not be archived") 1126 - } 1127 - }) 626 + article.URL = "http://wikipedia.org" 627 + if !article.IsValidURL() { 628 + t.Errorf("%v should be valid", article.URL) 629 + } 1128 630 }) 1129 631 1130 632 t.Run("TimeEntry Model", func(t *testing.T) { ··· 1229 731 } 1230 732 }) 1231 733 }) 734 + }) 1232 735 1233 - t.Run("Model Interface Implementation", func(t *testing.T) { 1234 - now := time.Now() 1235 - te := &TimeEntry{ 1236 - ID: 1, 1237 - TaskID: 100, 1238 - Created: now, 1239 - Modified: now, 736 + t.Run("Error Handling", func(t *testing.T) { 737 + t.Run("Marshaling Errors", func(t *testing.T) { 738 + t.Run("UnmarshalTags handles invalid JSON", func(t *testing.T) { 739 + task := &Task{} 740 + if err := task.UnmarshalTags(`{"invalid": "json"}`); err == nil { 741 + t.Error("Expected error for invalid JSON, got nil") 742 + } 743 + }) 744 + 745 + t.Run("UnmarshalAnnotations handles invalid JSON", func(t *testing.T) { 746 + task := &Task{} 747 + if err := task.UnmarshalAnnotations(`{"invalid": "json"}`); err == nil { 748 + t.Error("Expected error for invalid JSON, got nil") 749 + } 750 + }) 751 + }) 752 + }) 753 + 754 + t.Run("Edge Cases", func(t *testing.T) { 755 + t.Run("Task with nil slices", func(t *testing.T) { 756 + task := &Task{ 757 + Tags: nil, 758 + Annotations: nil, 1240 759 } 1241 760 1242 - if te.GetID() != 1 { 1243 - t.Errorf("Expected ID 1, got %d", te.GetID()) 761 + if tagsJSON, err := task.MarshalTags(); err != nil { 762 + t.Errorf("MarshalTags with nil slice failed: %v", err) 763 + } else if tagsJSON != "" { 764 + t.Errorf("Expected empty string for nil tags, got '%s'", tagsJSON) 1244 765 } 1245 766 1246 - te.SetID(2) 1247 - if te.GetID() != 2 { 1248 - t.Errorf("Expected ID 2 after SetID, got %d", te.GetID()) 767 + if annotationsJSON, err := task.MarshalAnnotations(); err != nil { 768 + t.Errorf("MarshalAnnotations with nil slice failed: %v", err) 769 + } else if annotationsJSON != "" { 770 + t.Errorf("Expected empty string for nil annotations, got '%s'", annotationsJSON) 1249 771 } 772 + }) 1250 773 1251 - if te.GetTableName() != "time_entries" { 1252 - t.Errorf("Expected table name 'time_entries', got '%s'", te.GetTableName()) 774 + t.Run("Models with zero values", func(t *testing.T) { 775 + task := &Task{} 776 + movie := &Movie{} 777 + tvShow := &TVShow{} 778 + book := &Book{} 779 + note := &Note{} 780 + 781 + if task.IsCompleted() || task.IsPending() || task.IsDeleted() { 782 + t.Error("Zero value task should have false status methods") 1253 783 } 1254 784 1255 - createdAt := time.Now() 1256 - te.SetCreatedAt(createdAt) 1257 - if !te.GetCreatedAt().Equal(createdAt) { 1258 - t.Errorf("Expected created at %v, got %v", createdAt, te.GetCreatedAt()) 785 + if movie.IsWatched() || movie.IsQueued() { 786 + t.Error("Zero value movie should have false status methods") 1259 787 } 1260 788 1261 - updatedAt := time.Now().Add(time.Hour) 1262 - te.SetUpdatedAt(updatedAt) 1263 - if !te.GetUpdatedAt().Equal(updatedAt) { 1264 - t.Errorf("Expected updated at %v, got %v", updatedAt, te.GetUpdatedAt()) 789 + if tvShow.IsWatching() || tvShow.IsWatched() || tvShow.IsQueued() { 790 + t.Error("Zero value TV show should have false status methods") 791 + } 792 + 793 + if book.IsReading() || book.IsFinished() || book.IsQueued() { 794 + t.Error("Zero value book should have false status methods") 795 + } 796 + 797 + if book.ProgressPercent() != 0 { 798 + t.Errorf("Zero value book should have 0%% progress, got %d%%", book.ProgressPercent()) 799 + } 800 + 801 + if note.IsArchived() { 802 + t.Error("Zero value note should not be archived") 1265 803 } 1266 804 }) 1267 805 })
+2
internal/store/sql/migrations/0006_create_articles_table_down.sql
··· 1 + -- Drop articles table 2 + DROP TABLE IF EXISTS articles;
+18
internal/store/sql/migrations/0006_create_articles_table_up.sql
··· 1 + -- Articles table 2 + CREATE TABLE IF NOT EXISTS articles ( 3 + id INTEGER PRIMARY KEY AUTOINCREMENT, 4 + url TEXT UNIQUE NOT NULL, 5 + title TEXT NOT NULL, 6 + author TEXT, 7 + date TEXT, 8 + markdown_path TEXT NOT NULL, 9 + html_path TEXT NOT NULL, 10 + created DATETIME DEFAULT CURRENT_TIMESTAMP, 11 + modified DATETIME DEFAULT CURRENT_TIMESTAMP 12 + ); 13 + 14 + CREATE INDEX IF NOT EXISTS idx_articles_url ON articles(url); 15 + CREATE INDEX IF NOT EXISTS idx_articles_title ON articles(title); 16 + CREATE INDEX IF NOT EXISTS idx_articles_author ON articles(author); 17 + CREATE INDEX IF NOT EXISTS idx_articles_date ON articles(date); 18 + CREATE INDEX IF NOT EXISTS idx_articles_created ON articles(created);