feat(dmenu): change history limit from count to days, add --most-visited flag
- commit
- cebf030b7b30d532c4bc50c196e86e3d6b21aabe
- author
- bnema · · 2025-12-29 19:13 UTC
- parent
- 76a4000
- signature
- GPG Unverified
2B82207D370127A0The key is unknown
18 files · +722 -20
M README.md +2 -1
| @@ -251,7 +251,8 @@ sudo apt install va-driver-all | |||
| 251 | 251 | - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select` | |
| 252 | 252 | - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select` | |
| 253 | 253 | - `dumber dmenu --interactive` # built-in TUI fuzzy finder | |
| 254 | - | - `dumber dmenu --max 200` # limit output entries | |
| 254 | + | - `dumber dmenu --days 7` # show history from last 7 days (default: 30) | |
| 255 | + | - `dumber dmenu --most-visited` # sort by visit count instead of recency | |
| 255 | 256 | - Manage browsing history: | |
| 256 | 257 | - `dumber history` # interactive history browser (timeline tabs + fuzzy search) | |
| 257 | 258 | - `dumber history --json` # output recent entries as JSON | |
| @@ -251,7 +251,8 @@ sudo apt install va-driver-all | |||||
| 251 | - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select` | 251 | - rofi: `dumber dmenu | rofi -dmenu -show-icons -p "🔍 " | dumber dmenu --select` | ||
| 252 | - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select` | 252 | - fuzzel: `dumber dmenu | fuzzel --dmenu -p "🔍 " | dumber dmenu --select` | ||
| 253 | - `dumber dmenu --interactive` # built-in TUI fuzzy finder | 253 | - `dumber dmenu --interactive` # built-in TUI fuzzy finder | ||
| 254 | - | - `dumber dmenu --max 200` # limit output entries | 254 | + | - `dumber dmenu --days 7` # show history from last 7 days (default: 30) |
| 255 | + | - `dumber dmenu --most-visited` # sort by visit count instead of recency | |||
| 255 | - Manage browsing history: | 256 | - Manage browsing history: | ||
| 256 | - `dumber history` # interactive history browser (timeline tabs + fuzzy search) | 257 | - `dumber history` # interactive history browser (timeline tabs + fuzzy search) | ||
| 257 | - `dumber history --json` # output recent entries as JSON | 258 | - `dumber history --json` # output recent entries as JSON | ||
M docs/CONFIG.md +10 -4
| @@ -83,14 +83,14 @@ These settings control the `dumber dmenu` CLI command for rofi/fuzzel integratio | |||
| 83 | 83 | ||
| 84 | 84 | | Key | Type | Default | Description | | |
| 85 | 85 | |-----|------|---------|-------------| | |
| 86 | - | | `dmenu.max_history_items` | int | `20` | Max items shown in launcher | | |
| 86 | + | | `dmenu.max_history_days` | int | `30` | Number of days of history to show (0 = all) | | |
| 87 | 87 | | `dmenu.show_visit_count` | bool | `true` | Show visit counts in output | | |
| 88 | 88 | | `dmenu.show_last_visited` | bool | `true` | Show last visited dates in output | | |
| 89 | 89 | | `dmenu.history_prefix` | string | `"🕒"` | Prefix for history items | | |
| 90 | 90 | | `dmenu.shortcut_prefix` | string | `"🔍"` | Prefix for shortcuts | | |
| 91 | 91 | | `dmenu.url_prefix` | string | `"🌐"` | Prefix for URLs | | |
| 92 | 92 | | `dmenu.date_format` | string | `"2006-01-02 15:04"` | Go time format string | | |
| 93 | - | | `dmenu.sort_by_visit_count` | bool | `true` | Sort by popularity | | |
| 93 | + | | `dmenu.sort_by_visit_count` | bool | `true` | Sort by visit count instead of recency | | |
| 94 | 94 | ||
| 95 | 95 | **CLI Usage:** | |
| 96 | 96 | ```bash | |
| @@ -101,8 +101,14 @@ dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select | |||
| 101 | 101 | # Interactive TUI mode | |
| 102 | 102 | dumber dmenu --interactive | |
| 103 | 103 | ||
| 104 | - | # Override max items via CLI flag | |
| 105 | - | dumber dmenu --max 50 | |
| 104 | + | # Override history days via CLI flag (show last 7 days) | |
| 105 | + | dumber dmenu --days 7 | |
| 106 | + | ||
| 107 | + | # Sort by most visited instead of recency | |
| 108 | + | dumber dmenu --most-visited | |
| 109 | + | ||
| 110 | + | # Combined: most visited from last 14 days | |
| 111 | + | dumber dmenu --days 14 --most-visited | |
| 106 | 112 | ``` | |
| 107 | 113 | ||
| 108 | 114 | ## Omnibox | |
| @@ -83,14 +83,14 @@ These settings control the `dumber dmenu` CLI command for rofi/fuzzel integratio | |||||
| 83 | 83 | ||||
| 84 | | Key | Type | Default | Description | | 84 | | Key | Type | Default | Description | | ||
| 85 | |-----|------|---------|-------------| | 85 | |-----|------|---------|-------------| | ||
| 86 | - | | `dmenu.max_history_items` | int | `20` | Max items shown in launcher | | 86 | + | | `dmenu.max_history_days` | int | `30` | Number of days of history to show (0 = all) | |
| 87 | | `dmenu.show_visit_count` | bool | `true` | Show visit counts in output | | 87 | | `dmenu.show_visit_count` | bool | `true` | Show visit counts in output | | ||
| 88 | | `dmenu.show_last_visited` | bool | `true` | Show last visited dates in output | | 88 | | `dmenu.show_last_visited` | bool | `true` | Show last visited dates in output | | ||
| 89 | | `dmenu.history_prefix` | string | `"🕒"` | Prefix for history items | | 89 | | `dmenu.history_prefix` | string | `"🕒"` | Prefix for history items | | ||
| 90 | | `dmenu.shortcut_prefix` | string | `"🔍"` | Prefix for shortcuts | | 90 | | `dmenu.shortcut_prefix` | string | `"🔍"` | Prefix for shortcuts | | ||
| 91 | | `dmenu.url_prefix` | string | `"🌐"` | Prefix for URLs | | 91 | | `dmenu.url_prefix` | string | `"🌐"` | Prefix for URLs | | ||
| 92 | | `dmenu.date_format` | string | `"2006-01-02 15:04"` | Go time format string | | 92 | | `dmenu.date_format` | string | `"2006-01-02 15:04"` | Go time format string | | ||
| 93 | - | | `dmenu.sort_by_visit_count` | bool | `true` | Sort by popularity | | 93 | + | | `dmenu.sort_by_visit_count` | bool | `true` | Sort by visit count instead of recency | |
| 94 | 94 | ||||
| 95 | **CLI Usage:** | 95 | **CLI Usage:** | ||
| 96 | ```bash | 96 | ```bash | ||
| @@ -101,8 +101,14 @@ dumber dmenu | fuzzel --dmenu -p "Browse: " | dumber dmenu --select | |||||
| 101 | # Interactive TUI mode | 101 | # Interactive TUI mode | ||
| 102 | dumber dmenu --interactive | 102 | dumber dmenu --interactive | ||
| 103 | 103 | ||||
| 104 | - | # Override max items via CLI flag | 104 | + | # Override history days via CLI flag (show last 7 days) |
| 105 | - | dumber dmenu --max 50 | 105 | + | dumber dmenu --days 7 |
| 106 | + | ||||
| 107 | + | # Sort by most visited instead of recency | |||
| 108 | + | dumber dmenu --most-visited | |||
| 109 | + | ||||
| 110 | + | # Combined: most visited from last 14 days | |||
| 111 | + | dumber dmenu --days 14 --most-visited | |||
| 106 | ``` | 112 | ``` | ||
| 107 | 113 | ||||
| 108 | ## Omnibox | 114 | ## Omnibox | ||
M internal/application/usecase/search_history.go +28 -0
| @@ -75,6 +75,34 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int | |||
| 75 | 75 | return entries, nil | |
| 76 | 76 | } | |
| 77 | 77 | ||
| 78 | + | // GetRecentSince retrieves history entries visited within the last N days. | |
| 79 | + | func (uc *SearchHistoryUseCase) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| 80 | + | if days <= 0 { | |
| 81 | + | days = 30 // Default to 30 days | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | entries, err := uc.historyRepo.GetRecentSince(ctx, days) | |
| 85 | + | if err != nil { | |
| 86 | + | return nil, fmt.Errorf("failed to get recent history since %d days: %w", days, err) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | return entries, nil | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // GetMostVisited retrieves history entries sorted by visit count within the last N days. | |
| 93 | + | func (uc *SearchHistoryUseCase) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| 94 | + | if days <= 0 { | |
| 95 | + | days = 30 // Default to 30 days | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | entries, err := uc.historyRepo.GetMostVisited(ctx, days) | |
| 99 | + | if err != nil { | |
| 100 | + | return nil, fmt.Errorf("failed to get most visited history: %w", err) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | return entries, nil | |
| 104 | + | } | |
| 105 | + | ||
| 78 | 106 | // FindByURL retrieves a history entry by its URL. | |
| 79 | 107 | func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) { | |
| 80 | 108 | log := logging.FromContext(ctx) | |
| @@ -75,6 +75,34 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int | |||||
| 75 | return entries, nil | 75 | return entries, nil | ||
| 76 | } | 76 | } | ||
| 77 | 77 | ||||
| 78 | + | // GetRecentSince retrieves history entries visited within the last N days. | |||
| 79 | + | func (uc *SearchHistoryUseCase) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |||
| 80 | + | if days <= 0 { | |||
| 81 | + | days = 30 // Default to 30 days | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | entries, err := uc.historyRepo.GetRecentSince(ctx, days) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return nil, fmt.Errorf("failed to get recent history since %d days: %w", days, err) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | return entries, nil | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // GetMostVisited retrieves history entries sorted by visit count within the last N days. | |||
| 93 | + | func (uc *SearchHistoryUseCase) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |||
| 94 | + | if days <= 0 { | |||
| 95 | + | days = 30 // Default to 30 days | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | entries, err := uc.historyRepo.GetMostVisited(ctx, days) | |||
| 99 | + | if err != nil { | |||
| 100 | + | return nil, fmt.Errorf("failed to get most visited history: %w", err) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | return entries, nil | |||
| 104 | + | } | |||
| 105 | + | ||||
| 78 | // FindByURL retrieves a history entry by its URL. | 106 | // FindByURL retrieves a history entry by its URL. | ||
| 79 | func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) { | 107 | func (uc *SearchHistoryUseCase) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) { | ||
| 80 | log := logging.FromContext(ctx) | 108 | log := logging.FromContext(ctx) | ||
A internal/application/usecase/search_history_test.go +153 -0
| @@ -0,0 +1,153 @@ | |||
| 1 | + | package usecase_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) { | |
| 17 | + | ctx := testContext() | |
| 18 | + | ||
| 19 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 20 | + | ||
| 21 | + | entries := []*entity.HistoryEntry{ | |
| 22 | + | {ID: 1, URL: "https://example.com", Title: "Example", LastVisited: time.Now()}, | |
| 23 | + | {ID: 2, URL: "https://github.com", Title: "GitHub", LastVisited: time.Now().Add(-24 * time.Hour)}, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil) | |
| 27 | + | ||
| 28 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 29 | + | ||
| 30 | + | result, err := uc.GetRecentSince(ctx, 30) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | require.Len(t, result, 2) | |
| 33 | + | assert.Equal(t, "https://example.com", result[0].URL) | |
| 34 | + | assert.Equal(t, "https://github.com", result[1].URL) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestSearchHistoryUseCase_GetRecentSince_DefaultsTo30Days(t *testing.T) { | |
| 38 | + | ctx := testContext() | |
| 39 | + | ||
| 40 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 41 | + | ||
| 42 | + | entries := []*entity.HistoryEntry{ | |
| 43 | + | {ID: 1, URL: "https://example.com", Title: "Example"}, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // When days <= 0, should default to 30 | |
| 47 | + | historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil) | |
| 48 | + | ||
| 49 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 50 | + | ||
| 51 | + | result, err := uc.GetRecentSince(ctx, 0) | |
| 52 | + | require.NoError(t, err) | |
| 53 | + | require.Len(t, result, 1) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestSearchHistoryUseCase_GetRecentSince_NegativeDaysDefaultsTo30(t *testing.T) { | |
| 57 | + | ctx := testContext() | |
| 58 | + | ||
| 59 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 60 | + | ||
| 61 | + | entries := []*entity.HistoryEntry{} | |
| 62 | + | ||
| 63 | + | historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil) | |
| 64 | + | ||
| 65 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 66 | + | ||
| 67 | + | result, err := uc.GetRecentSince(ctx, -5) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | require.Empty(t, result) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestSearchHistoryUseCase_GetRecentSince_ReturnsErrorOnRepoFailure(t *testing.T) { | |
| 73 | + | ctx := testContext() | |
| 74 | + | ||
| 75 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 76 | + | ||
| 77 | + | historyRepo.EXPECT().GetRecentSince(mock.Anything, 7).Return(nil, errors.New("db connection failed")) | |
| 78 | + | ||
| 79 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 80 | + | ||
| 81 | + | result, err := uc.GetRecentSince(ctx, 7) | |
| 82 | + | require.Error(t, err) | |
| 83 | + | assert.Nil(t, result) | |
| 84 | + | assert.Contains(t, err.Error(), "failed to get recent history since 7 days") | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestSearchHistoryUseCase_GetMostVisited_ReturnsEntries(t *testing.T) { | |
| 88 | + | ctx := testContext() | |
| 89 | + | ||
| 90 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 91 | + | ||
| 92 | + | entries := []*entity.HistoryEntry{ | |
| 93 | + | {ID: 1, URL: "https://github.com", Title: "GitHub", VisitCount: 100}, | |
| 94 | + | {ID: 2, URL: "https://example.com", Title: "Example", VisitCount: 50}, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return(entries, nil) | |
| 98 | + | ||
| 99 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 100 | + | ||
| 101 | + | result, err := uc.GetMostVisited(ctx, 30) | |
| 102 | + | require.NoError(t, err) | |
| 103 | + | require.Len(t, result, 2) | |
| 104 | + | assert.Equal(t, "https://github.com", result[0].URL) | |
| 105 | + | assert.Equal(t, int64(100), result[0].VisitCount) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestSearchHistoryUseCase_GetMostVisited_DefaultsTo30Days(t *testing.T) { | |
| 109 | + | ctx := testContext() | |
| 110 | + | ||
| 111 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 112 | + | ||
| 113 | + | entries := []*entity.HistoryEntry{} | |
| 114 | + | ||
| 115 | + | // When days <= 0, should default to 30 | |
| 116 | + | historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return(entries, nil) | |
| 117 | + | ||
| 118 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 119 | + | ||
| 120 | + | result, err := uc.GetMostVisited(ctx, 0) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | require.Empty(t, result) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func TestSearchHistoryUseCase_GetMostVisited_ReturnsErrorOnRepoFailure(t *testing.T) { | |
| 126 | + | ctx := testContext() | |
| 127 | + | ||
| 128 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 129 | + | ||
| 130 | + | historyRepo.EXPECT().GetMostVisited(mock.Anything, 14).Return(nil, errors.New("query failed")) | |
| 131 | + | ||
| 132 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 133 | + | ||
| 134 | + | result, err := uc.GetMostVisited(ctx, 14) | |
| 135 | + | require.Error(t, err) | |
| 136 | + | assert.Nil(t, result) | |
| 137 | + | assert.Contains(t, err.Error(), "failed to get most visited history") | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testing.T) { | |
| 141 | + | ctx := testContext() | |
| 142 | + | ||
| 143 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 144 | + | ||
| 145 | + | historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return([]*entity.HistoryEntry{}, nil) | |
| 146 | + | ||
| 147 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 148 | + | ||
| 149 | + | result, err := uc.GetMostVisited(ctx, 30) | |
| 150 | + | require.NoError(t, err) | |
| 151 | + | require.NotNil(t, result) | |
| 152 | + | assert.Empty(t, result) | |
| 153 | + | } | |
| @@ -0,0 +1,153 @@ | |||||
| 1 | + | package usecase_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 10 | + | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) { | |||
| 17 | + | ctx := testContext() | |||
| 18 | + | ||||
| 19 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 20 | + | ||||
| 21 | + | entries := []*entity.HistoryEntry{ | |||
| 22 | + | {ID: 1, URL: "https://example.com", Title: "Example", LastVisited: time.Now()}, | |||
| 23 | + | {ID: 2, URL: "https://github.com", Title: "GitHub", LastVisited: time.Now().Add(-24 * time.Hour)}, | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil) | |||
| 27 | + | ||||
| 28 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 29 | + | ||||
| 30 | + | result, err := uc.GetRecentSince(ctx, 30) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | require.Len(t, result, 2) | |||
| 33 | + | assert.Equal(t, "https://example.com", result[0].URL) | |||
| 34 | + | assert.Equal(t, "https://github.com", result[1].URL) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestSearchHistoryUseCase_GetRecentSince_DefaultsTo30Days(t *testing.T) { | |||
| 38 | + | ctx := testContext() | |||
| 39 | + | ||||
| 40 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 41 | + | ||||
| 42 | + | entries := []*entity.HistoryEntry{ | |||
| 43 | + | {ID: 1, URL: "https://example.com", Title: "Example"}, | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // When days <= 0, should default to 30 | |||
| 47 | + | historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil) | |||
| 48 | + | ||||
| 49 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 50 | + | ||||
| 51 | + | result, err := uc.GetRecentSince(ctx, 0) | |||
| 52 | + | require.NoError(t, err) | |||
| 53 | + | require.Len(t, result, 1) | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func TestSearchHistoryUseCase_GetRecentSince_NegativeDaysDefaultsTo30(t *testing.T) { | |||
| 57 | + | ctx := testContext() | |||
| 58 | + | ||||
| 59 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 60 | + | ||||
| 61 | + | entries := []*entity.HistoryEntry{} | |||
| 62 | + | ||||
| 63 | + | historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil) | |||
| 64 | + | ||||
| 65 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 66 | + | ||||
| 67 | + | result, err := uc.GetRecentSince(ctx, -5) | |||
| 68 | + | require.NoError(t, err) | |||
| 69 | + | require.Empty(t, result) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestSearchHistoryUseCase_GetRecentSince_ReturnsErrorOnRepoFailure(t *testing.T) { | |||
| 73 | + | ctx := testContext() | |||
| 74 | + | ||||
| 75 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 76 | + | ||||
| 77 | + | historyRepo.EXPECT().GetRecentSince(mock.Anything, 7).Return(nil, errors.New("db connection failed")) | |||
| 78 | + | ||||
| 79 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 80 | + | ||||
| 81 | + | result, err := uc.GetRecentSince(ctx, 7) | |||
| 82 | + | require.Error(t, err) | |||
| 83 | + | assert.Nil(t, result) | |||
| 84 | + | assert.Contains(t, err.Error(), "failed to get recent history since 7 days") | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestSearchHistoryUseCase_GetMostVisited_ReturnsEntries(t *testing.T) { | |||
| 88 | + | ctx := testContext() | |||
| 89 | + | ||||
| 90 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 91 | + | ||||
| 92 | + | entries := []*entity.HistoryEntry{ | |||
| 93 | + | {ID: 1, URL: "https://github.com", Title: "GitHub", VisitCount: 100}, | |||
| 94 | + | {ID: 2, URL: "https://example.com", Title: "Example", VisitCount: 50}, | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return(entries, nil) | |||
| 98 | + | ||||
| 99 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 100 | + | ||||
| 101 | + | result, err := uc.GetMostVisited(ctx, 30) | |||
| 102 | + | require.NoError(t, err) | |||
| 103 | + | require.Len(t, result, 2) | |||
| 104 | + | assert.Equal(t, "https://github.com", result[0].URL) | |||
| 105 | + | assert.Equal(t, int64(100), result[0].VisitCount) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestSearchHistoryUseCase_GetMostVisited_DefaultsTo30Days(t *testing.T) { | |||
| 109 | + | ctx := testContext() | |||
| 110 | + | ||||
| 111 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 112 | + | ||||
| 113 | + | entries := []*entity.HistoryEntry{} | |||
| 114 | + | ||||
| 115 | + | // When days <= 0, should default to 30 | |||
| 116 | + | historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return(entries, nil) | |||
| 117 | + | ||||
| 118 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 119 | + | ||||
| 120 | + | result, err := uc.GetMostVisited(ctx, 0) | |||
| 121 | + | require.NoError(t, err) | |||
| 122 | + | require.Empty(t, result) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func TestSearchHistoryUseCase_GetMostVisited_ReturnsErrorOnRepoFailure(t *testing.T) { | |||
| 126 | + | ctx := testContext() | |||
| 127 | + | ||||
| 128 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 129 | + | ||||
| 130 | + | historyRepo.EXPECT().GetMostVisited(mock.Anything, 14).Return(nil, errors.New("query failed")) | |||
| 131 | + | ||||
| 132 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 133 | + | ||||
| 134 | + | result, err := uc.GetMostVisited(ctx, 14) | |||
| 135 | + | require.Error(t, err) | |||
| 136 | + | assert.Nil(t, result) | |||
| 137 | + | assert.Contains(t, err.Error(), "failed to get most visited history") | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testing.T) { | |||
| 141 | + | ctx := testContext() | |||
| 142 | + | ||||
| 143 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 144 | + | ||||
| 145 | + | historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return([]*entity.HistoryEntry{}, nil) | |||
| 146 | + | ||||
| 147 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 148 | + | ||||
| 149 | + | result, err := uc.GetMostVisited(ctx, 30) | |||
| 150 | + | require.NoError(t, err) | |||
| 151 | + | require.NotNil(t, result) | |||
| 152 | + | assert.Empty(t, result) | |||
| 153 | + | } | |||
M internal/cli/cmd/dmenu.go +21 -6
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/dumber/assets" | |
| 15 | 15 | "github.com/bnema/dumber/internal/cli/model" | |
| 16 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 16 | 17 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 17 | 18 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 18 | 19 | "github.com/bnema/dumber/internal/infrastructure/favicon" | |
| @@ -22,7 +23,8 @@ import ( | |||
| 22 | 23 | var ( | |
| 23 | 24 | dmenuInteractive bool | |
| 24 | 25 | dmenuSelect bool | |
| 25 | - | dmenuMax int | |
| 26 | + | dmenuDays int | |
| 27 | + | dmenuMostVisited bool | |
| 26 | 28 | ) | |
| 27 | 29 | ||
| 28 | 30 | var dmenuCmd = &cobra.Command{ | |
| @@ -43,7 +45,8 @@ func init() { | |||
| 43 | 45 | ||
| 44 | 46 | dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode") | |
| 45 | 47 | dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin") | |
| 46 | - | dmenuCmd.Flags().IntVar(&dmenuMax, "max", 0, "maximum entries to output (default from config)") | |
| 48 | + | dmenuCmd.Flags().IntVar(&dmenuDays, "days", 0, "number of days of history to show (default from config)") | |
| 49 | + | dmenuCmd.Flags().BoolVarP(&dmenuMostVisited, "most-visited", "m", false, "sort by visit count instead of recency") | |
| 47 | 50 | } | |
| 48 | 51 | ||
| 49 | 52 | func runDmenu(_ *cobra.Command, _ []string) error { | |
| @@ -71,9 +74,14 @@ func getDmenuConfig() config.DmenuConfig { | |||
| 71 | 74 | app := GetApp() | |
| 72 | 75 | cfg := app.Config.Dmenu | |
| 73 | 76 | ||
| 74 | - | // Override max if explicitly set via CLI flag | |
| 75 | - | if dmenuMax > 0 { | |
| 76 | - | cfg.MaxHistoryItems = dmenuMax | |
| 77 | + | // Override days if explicitly set via CLI flag | |
| 78 | + | if dmenuDays > 0 { | |
| 79 | + | cfg.MaxHistoryDays = dmenuDays | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Override sort_by_visit_count if --most-visited flag is set | |
| 83 | + | if dmenuMostVisited { | |
| 84 | + | cfg.SortByVisitCount = true | |
| 77 | 85 | } | |
| 78 | 86 | ||
| 79 | 87 | return cfg | |
| @@ -131,7 +139,14 @@ func runDmenuPipe() error { | |||
| 131 | 139 | ctx := context.Background() | |
| 132 | 140 | cfg := getDmenuConfig() | |
| 133 | 141 | ||
| 134 | - | entries, err := app.SearchHistoryUC.GetRecent(ctx, cfg.MaxHistoryItems, 0) | |
| 142 | + | var entries []*entity.HistoryEntry | |
| 143 | + | var err error | |
| 144 | + | ||
| 145 | + | if cfg.SortByVisitCount { | |
| 146 | + | entries, err = app.SearchHistoryUC.GetMostVisited(ctx, cfg.MaxHistoryDays) | |
| 147 | + | } else { | |
| 148 | + | entries, err = app.SearchHistoryUC.GetRecentSince(ctx, cfg.MaxHistoryDays) | |
| 149 | + | } | |
| 135 | 150 | if err != nil { | |
| 136 | 151 | return fmt.Errorf("get history: %w", err) | |
| 137 | 152 | } | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | "github.com/bnema/dumber/assets" | 14 | "github.com/bnema/dumber/assets" | ||
| 15 | "github.com/bnema/dumber/internal/cli/model" | 15 | "github.com/bnema/dumber/internal/cli/model" | ||
| 16 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 16 | domainurl "github.com/bnema/dumber/internal/domain/url" | 17 | domainurl "github.com/bnema/dumber/internal/domain/url" | ||
| 17 | "github.com/bnema/dumber/internal/infrastructure/config" | 18 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 18 | "github.com/bnema/dumber/internal/infrastructure/favicon" | 19 | "github.com/bnema/dumber/internal/infrastructure/favicon" | ||
| @@ -22,7 +23,8 @@ import ( | |||||
| 22 | var ( | 23 | var ( | ||
| 23 | dmenuInteractive bool | 24 | dmenuInteractive bool | ||
| 24 | dmenuSelect bool | 25 | dmenuSelect bool | ||
| 25 | - | dmenuMax int | 26 | + | dmenuDays int |
| 27 | + | dmenuMostVisited bool | |||
| 26 | ) | 28 | ) | ||
| 27 | 29 | ||||
| 28 | var dmenuCmd = &cobra.Command{ | 30 | var dmenuCmd = &cobra.Command{ | ||
| @@ -43,7 +45,8 @@ func init() { | |||||
| 43 | 45 | ||||
| 44 | dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode") | 46 | dmenuCmd.Flags().BoolVarP(&dmenuInteractive, "interactive", "i", false, "use interactive TUI mode") | ||
| 45 | dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin") | 47 | dmenuCmd.Flags().BoolVar(&dmenuSelect, "select", false, "process selection from stdin") | ||
| 46 | - | dmenuCmd.Flags().IntVar(&dmenuMax, "max", 0, "maximum entries to output (default from config)") | 48 | + | dmenuCmd.Flags().IntVar(&dmenuDays, "days", 0, "number of days of history to show (default from config)") |
| 49 | + | dmenuCmd.Flags().BoolVarP(&dmenuMostVisited, "most-visited", "m", false, "sort by visit count instead of recency") | |||
| 47 | } | 50 | } | ||
| 48 | 51 | ||||
| 49 | func runDmenu(_ *cobra.Command, _ []string) error { | 52 | func runDmenu(_ *cobra.Command, _ []string) error { | ||
| @@ -71,9 +74,14 @@ func getDmenuConfig() config.DmenuConfig { | |||||
| 71 | app := GetApp() | 74 | app := GetApp() | ||
| 72 | cfg := app.Config.Dmenu | 75 | cfg := app.Config.Dmenu | ||
| 73 | 76 | ||||
| 74 | - | // Override max if explicitly set via CLI flag | 77 | + | // Override days if explicitly set via CLI flag |
| 75 | - | if dmenuMax > 0 { | 78 | + | if dmenuDays > 0 { |
| 76 | - | cfg.MaxHistoryItems = dmenuMax | 79 | + | cfg.MaxHistoryDays = dmenuDays |
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Override sort_by_visit_count if --most-visited flag is set | |||
| 83 | + | if dmenuMostVisited { | |||
| 84 | + | cfg.SortByVisitCount = true | |||
| 77 | } | 85 | } | ||
| 78 | 86 | ||||
| 79 | return cfg | 87 | return cfg | ||
| @@ -131,7 +139,14 @@ func runDmenuPipe() error { | |||||
| 131 | ctx := context.Background() | 139 | ctx := context.Background() | ||
| 132 | cfg := getDmenuConfig() | 140 | cfg := getDmenuConfig() | ||
| 133 | 141 | ||||
| 134 | - | entries, err := app.SearchHistoryUC.GetRecent(ctx, cfg.MaxHistoryItems, 0) | 142 | + | var entries []*entity.HistoryEntry |
| 143 | + | var err error | |||
| 144 | + | ||||
| 145 | + | if cfg.SortByVisitCount { | |||
| 146 | + | entries, err = app.SearchHistoryUC.GetMostVisited(ctx, cfg.MaxHistoryDays) | |||
| 147 | + | } else { | |||
| 148 | + | entries, err = app.SearchHistoryUC.GetRecentSince(ctx, cfg.MaxHistoryDays) | |||
| 149 | + | } | |||
| 135 | if err != nil { | 150 | if err != nil { | ||
| 136 | return fmt.Errorf("get history: %w", err) | 151 | return fmt.Errorf("get history: %w", err) | ||
| 137 | } | 152 | } | ||
M internal/domain/repository/history.go +6 -0
| @@ -21,6 +21,12 @@ type HistoryRepository interface { | |||
| 21 | 21 | // GetRecent retrieves recent history entries with pagination. | |
| 22 | 22 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | |
| 23 | 23 | ||
| 24 | + | // GetRecentSince retrieves history entries visited within the last N days. | |
| 25 | + | GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) | |
| 26 | + | ||
| 27 | + | // GetMostVisited retrieves history entries sorted by visit count within the last N days. | |
| 28 | + | GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) | |
| 29 | + | ||
| 24 | 30 | // IncrementVisitCount increments the visit count for a URL. | |
| 25 | 31 | IncrementVisitCount(ctx context.Context, url string) error | |
| 26 | 32 | ||
| @@ -21,6 +21,12 @@ type HistoryRepository interface { | |||||
| 21 | // GetRecent retrieves recent history entries with pagination. | 21 | // GetRecent retrieves recent history entries with pagination. | ||
| 22 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | 22 | GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) | ||
| 23 | 23 | ||||
| 24 | + | // GetRecentSince retrieves history entries visited within the last N days. | |||
| 25 | + | GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) | |||
| 26 | + | ||||
| 27 | + | // GetMostVisited retrieves history entries sorted by visit count within the last N days. | |||
| 28 | + | GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) | |||
| 29 | + | ||||
| 24 | // IncrementVisitCount increments the visit count for a URL. | 30 | // IncrementVisitCount increments the visit count for a URL. | ||
| 25 | IncrementVisitCount(ctx context.Context, url string) error | 31 | IncrementVisitCount(ctx context.Context, url string) error | ||
| 26 | 32 | ||||
M internal/domain/repository/mocks/mocks.go +136 -0
| @@ -2395,6 +2395,74 @@ func (_c *MockHistoryRepository_GetHourlyDistribution_Call) RunAndReturn(run fun | |||
| 2395 | 2395 | return _c | |
| 2396 | 2396 | } | |
| 2397 | 2397 | ||
| 2398 | + | // GetMostVisited provides a mock function for the type MockHistoryRepository | |
| 2399 | + | func (_mock *MockHistoryRepository) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| 2400 | + | ret := _mock.Called(ctx, days) | |
| 2401 | + | ||
| 2402 | + | if len(ret) == 0 { | |
| 2403 | + | panic("no return value specified for GetMostVisited") | |
| 2404 | + | } | |
| 2405 | + | ||
| 2406 | + | var r0 []*entity.HistoryEntry | |
| 2407 | + | var r1 error | |
| 2408 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.HistoryEntry, error)); ok { | |
| 2409 | + | return returnFunc(ctx, days) | |
| 2410 | + | } | |
| 2411 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.HistoryEntry); ok { | |
| 2412 | + | r0 = returnFunc(ctx, days) | |
| 2413 | + | } else { | |
| 2414 | + | if ret.Get(0) != nil { | |
| 2415 | + | r0 = ret.Get(0).([]*entity.HistoryEntry) | |
| 2416 | + | } | |
| 2417 | + | } | |
| 2418 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok { | |
| 2419 | + | r1 = returnFunc(ctx, days) | |
| 2420 | + | } else { | |
| 2421 | + | r1 = ret.Error(1) | |
| 2422 | + | } | |
| 2423 | + | return r0, r1 | |
| 2424 | + | } | |
| 2425 | + | ||
| 2426 | + | // MockHistoryRepository_GetMostVisited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMostVisited' | |
| 2427 | + | type MockHistoryRepository_GetMostVisited_Call struct { | |
| 2428 | + | *mock.Call | |
| 2429 | + | } | |
| 2430 | + | ||
| 2431 | + | // GetMostVisited is a helper method to define mock.On call | |
| 2432 | + | // - ctx context.Context | |
| 2433 | + | // - days int | |
| 2434 | + | func (_e *MockHistoryRepository_Expecter) GetMostVisited(ctx interface{}, days interface{}) *MockHistoryRepository_GetMostVisited_Call { | |
| 2435 | + | return &MockHistoryRepository_GetMostVisited_Call{Call: _e.mock.On("GetMostVisited", ctx, days)} | |
| 2436 | + | } | |
| 2437 | + | ||
| 2438 | + | func (_c *MockHistoryRepository_GetMostVisited_Call) Run(run func(ctx context.Context, days int)) *MockHistoryRepository_GetMostVisited_Call { | |
| 2439 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2440 | + | var arg0 context.Context | |
| 2441 | + | if args[0] != nil { | |
| 2442 | + | arg0 = args[0].(context.Context) | |
| 2443 | + | } | |
| 2444 | + | var arg1 int | |
| 2445 | + | if args[1] != nil { | |
| 2446 | + | arg1 = args[1].(int) | |
| 2447 | + | } | |
| 2448 | + | run( | |
| 2449 | + | arg0, | |
| 2450 | + | arg1, | |
| 2451 | + | ) | |
| 2452 | + | }) | |
| 2453 | + | return _c | |
| 2454 | + | } | |
| 2455 | + | ||
| 2456 | + | func (_c *MockHistoryRepository_GetMostVisited_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetMostVisited_Call { | |
| 2457 | + | _c.Call.Return(historyEntrys, err) | |
| 2458 | + | return _c | |
| 2459 | + | } | |
| 2460 | + | ||
| 2461 | + | func (_c *MockHistoryRepository_GetMostVisited_Call) RunAndReturn(run func(ctx context.Context, days int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetMostVisited_Call { | |
| 2462 | + | _c.Call.Return(run) | |
| 2463 | + | return _c | |
| 2464 | + | } | |
| 2465 | + | ||
| 2398 | 2466 | // GetRecent provides a mock function for the type MockHistoryRepository | |
| 2399 | 2467 | func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) { | |
| 2400 | 2468 | ret := _mock.Called(ctx, limit, offset) | |
| @@ -2469,6 +2537,74 @@ func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx contex | |||
| 2469 | 2537 | return _c | |
| 2470 | 2538 | } | |
| 2471 | 2539 | ||
| 2540 | + | // GetRecentSince provides a mock function for the type MockHistoryRepository | |
| 2541 | + | func (_mock *MockHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| 2542 | + | ret := _mock.Called(ctx, days) | |
| 2543 | + | ||
| 2544 | + | if len(ret) == 0 { | |
| 2545 | + | panic("no return value specified for GetRecentSince") | |
| 2546 | + | } | |
| 2547 | + | ||
| 2548 | + | var r0 []*entity.HistoryEntry | |
| 2549 | + | var r1 error | |
| 2550 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.HistoryEntry, error)); ok { | |
| 2551 | + | return returnFunc(ctx, days) | |
| 2552 | + | } | |
| 2553 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.HistoryEntry); ok { | |
| 2554 | + | r0 = returnFunc(ctx, days) | |
| 2555 | + | } else { | |
| 2556 | + | if ret.Get(0) != nil { | |
| 2557 | + | r0 = ret.Get(0).([]*entity.HistoryEntry) | |
| 2558 | + | } | |
| 2559 | + | } | |
| 2560 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok { | |
| 2561 | + | r1 = returnFunc(ctx, days) | |
| 2562 | + | } else { | |
| 2563 | + | r1 = ret.Error(1) | |
| 2564 | + | } | |
| 2565 | + | return r0, r1 | |
| 2566 | + | } | |
| 2567 | + | ||
| 2568 | + | // MockHistoryRepository_GetRecentSince_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentSince' | |
| 2569 | + | type MockHistoryRepository_GetRecentSince_Call struct { | |
| 2570 | + | *mock.Call | |
| 2571 | + | } | |
| 2572 | + | ||
| 2573 | + | // GetRecentSince is a helper method to define mock.On call | |
| 2574 | + | // - ctx context.Context | |
| 2575 | + | // - days int | |
| 2576 | + | func (_e *MockHistoryRepository_Expecter) GetRecentSince(ctx interface{}, days interface{}) *MockHistoryRepository_GetRecentSince_Call { | |
| 2577 | + | return &MockHistoryRepository_GetRecentSince_Call{Call: _e.mock.On("GetRecentSince", ctx, days)} | |
| 2578 | + | } | |
| 2579 | + | ||
| 2580 | + | func (_c *MockHistoryRepository_GetRecentSince_Call) Run(run func(ctx context.Context, days int)) *MockHistoryRepository_GetRecentSince_Call { | |
| 2581 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 2582 | + | var arg0 context.Context | |
| 2583 | + | if args[0] != nil { | |
| 2584 | + | arg0 = args[0].(context.Context) | |
| 2585 | + | } | |
| 2586 | + | var arg1 int | |
| 2587 | + | if args[1] != nil { | |
| 2588 | + | arg1 = args[1].(int) | |
| 2589 | + | } | |
| 2590 | + | run( | |
| 2591 | + | arg0, | |
| 2592 | + | arg1, | |
| 2593 | + | ) | |
| 2594 | + | }) | |
| 2595 | + | return _c | |
| 2596 | + | } | |
| 2597 | + | ||
| 2598 | + | func (_c *MockHistoryRepository_GetRecentSince_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentSince_Call { | |
| 2599 | + | _c.Call.Return(historyEntrys, err) | |
| 2600 | + | return _c | |
| 2601 | + | } | |
| 2602 | + | ||
| 2603 | + | func (_c *MockHistoryRepository_GetRecentSince_Call) RunAndReturn(run func(ctx context.Context, days int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentSince_Call { | |
| 2604 | + | _c.Call.Return(run) | |
| 2605 | + | return _c | |
| 2606 | + | } | |
| 2607 | + | ||
| 2472 | 2608 | // GetStats provides a mock function for the type MockHistoryRepository | |
| 2473 | 2609 | func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | |
| 2474 | 2610 | ret := _mock.Called(ctx) | |
| @@ -2395,6 +2395,74 @@ func (_c *MockHistoryRepository_GetHourlyDistribution_Call) RunAndReturn(run fun | |||||
| 2395 | return _c | 2395 | return _c | ||
| 2396 | } | 2396 | } | ||
| 2397 | 2397 | ||||
| 2398 | + | // GetMostVisited provides a mock function for the type MockHistoryRepository | |||
| 2399 | + | func (_mock *MockHistoryRepository) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |||
| 2400 | + | ret := _mock.Called(ctx, days) | |||
| 2401 | + | ||||
| 2402 | + | if len(ret) == 0 { | |||
| 2403 | + | panic("no return value specified for GetMostVisited") | |||
| 2404 | + | } | |||
| 2405 | + | ||||
| 2406 | + | var r0 []*entity.HistoryEntry | |||
| 2407 | + | var r1 error | |||
| 2408 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.HistoryEntry, error)); ok { | |||
| 2409 | + | return returnFunc(ctx, days) | |||
| 2410 | + | } | |||
| 2411 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.HistoryEntry); ok { | |||
| 2412 | + | r0 = returnFunc(ctx, days) | |||
| 2413 | + | } else { | |||
| 2414 | + | if ret.Get(0) != nil { | |||
| 2415 | + | r0 = ret.Get(0).([]*entity.HistoryEntry) | |||
| 2416 | + | } | |||
| 2417 | + | } | |||
| 2418 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok { | |||
| 2419 | + | r1 = returnFunc(ctx, days) | |||
| 2420 | + | } else { | |||
| 2421 | + | r1 = ret.Error(1) | |||
| 2422 | + | } | |||
| 2423 | + | return r0, r1 | |||
| 2424 | + | } | |||
| 2425 | + | ||||
| 2426 | + | // MockHistoryRepository_GetMostVisited_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMostVisited' | |||
| 2427 | + | type MockHistoryRepository_GetMostVisited_Call struct { | |||
| 2428 | + | *mock.Call | |||
| 2429 | + | } | |||
| 2430 | + | ||||
| 2431 | + | // GetMostVisited is a helper method to define mock.On call | |||
| 2432 | + | // - ctx context.Context | |||
| 2433 | + | // - days int | |||
| 2434 | + | func (_e *MockHistoryRepository_Expecter) GetMostVisited(ctx interface{}, days interface{}) *MockHistoryRepository_GetMostVisited_Call { | |||
| 2435 | + | return &MockHistoryRepository_GetMostVisited_Call{Call: _e.mock.On("GetMostVisited", ctx, days)} | |||
| 2436 | + | } | |||
| 2437 | + | ||||
| 2438 | + | func (_c *MockHistoryRepository_GetMostVisited_Call) Run(run func(ctx context.Context, days int)) *MockHistoryRepository_GetMostVisited_Call { | |||
| 2439 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2440 | + | var arg0 context.Context | |||
| 2441 | + | if args[0] != nil { | |||
| 2442 | + | arg0 = args[0].(context.Context) | |||
| 2443 | + | } | |||
| 2444 | + | var arg1 int | |||
| 2445 | + | if args[1] != nil { | |||
| 2446 | + | arg1 = args[1].(int) | |||
| 2447 | + | } | |||
| 2448 | + | run( | |||
| 2449 | + | arg0, | |||
| 2450 | + | arg1, | |||
| 2451 | + | ) | |||
| 2452 | + | }) | |||
| 2453 | + | return _c | |||
| 2454 | + | } | |||
| 2455 | + | ||||
| 2456 | + | func (_c *MockHistoryRepository_GetMostVisited_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetMostVisited_Call { | |||
| 2457 | + | _c.Call.Return(historyEntrys, err) | |||
| 2458 | + | return _c | |||
| 2459 | + | } | |||
| 2460 | + | ||||
| 2461 | + | func (_c *MockHistoryRepository_GetMostVisited_Call) RunAndReturn(run func(ctx context.Context, days int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetMostVisited_Call { | |||
| 2462 | + | _c.Call.Return(run) | |||
| 2463 | + | return _c | |||
| 2464 | + | } | |||
| 2465 | + | ||||
| 2398 | // GetRecent provides a mock function for the type MockHistoryRepository | 2466 | // GetRecent provides a mock function for the type MockHistoryRepository | ||
| 2399 | func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) { | 2467 | func (_mock *MockHistoryRepository) GetRecent(ctx context.Context, limit int, offset int) ([]*entity.HistoryEntry, error) { | ||
| 2400 | ret := _mock.Called(ctx, limit, offset) | 2468 | ret := _mock.Called(ctx, limit, offset) | ||
| @@ -2469,6 +2537,74 @@ func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx contex | |||||
| 2469 | return _c | 2537 | return _c | ||
| 2470 | } | 2538 | } | ||
| 2471 | 2539 | ||||
| 2540 | + | // GetRecentSince provides a mock function for the type MockHistoryRepository | |||
| 2541 | + | func (_mock *MockHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |||
| 2542 | + | ret := _mock.Called(ctx, days) | |||
| 2543 | + | ||||
| 2544 | + | if len(ret) == 0 { | |||
| 2545 | + | panic("no return value specified for GetRecentSince") | |||
| 2546 | + | } | |||
| 2547 | + | ||||
| 2548 | + | var r0 []*entity.HistoryEntry | |||
| 2549 | + | var r1 error | |||
| 2550 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) ([]*entity.HistoryEntry, error)); ok { | |||
| 2551 | + | return returnFunc(ctx, days) | |||
| 2552 | + | } | |||
| 2553 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, int) []*entity.HistoryEntry); ok { | |||
| 2554 | + | r0 = returnFunc(ctx, days) | |||
| 2555 | + | } else { | |||
| 2556 | + | if ret.Get(0) != nil { | |||
| 2557 | + | r0 = ret.Get(0).([]*entity.HistoryEntry) | |||
| 2558 | + | } | |||
| 2559 | + | } | |||
| 2560 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok { | |||
| 2561 | + | r1 = returnFunc(ctx, days) | |||
| 2562 | + | } else { | |||
| 2563 | + | r1 = ret.Error(1) | |||
| 2564 | + | } | |||
| 2565 | + | return r0, r1 | |||
| 2566 | + | } | |||
| 2567 | + | ||||
| 2568 | + | // MockHistoryRepository_GetRecentSince_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentSince' | |||
| 2569 | + | type MockHistoryRepository_GetRecentSince_Call struct { | |||
| 2570 | + | *mock.Call | |||
| 2571 | + | } | |||
| 2572 | + | ||||
| 2573 | + | // GetRecentSince is a helper method to define mock.On call | |||
| 2574 | + | // - ctx context.Context | |||
| 2575 | + | // - days int | |||
| 2576 | + | func (_e *MockHistoryRepository_Expecter) GetRecentSince(ctx interface{}, days interface{}) *MockHistoryRepository_GetRecentSince_Call { | |||
| 2577 | + | return &MockHistoryRepository_GetRecentSince_Call{Call: _e.mock.On("GetRecentSince", ctx, days)} | |||
| 2578 | + | } | |||
| 2579 | + | ||||
| 2580 | + | func (_c *MockHistoryRepository_GetRecentSince_Call) Run(run func(ctx context.Context, days int)) *MockHistoryRepository_GetRecentSince_Call { | |||
| 2581 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 2582 | + | var arg0 context.Context | |||
| 2583 | + | if args[0] != nil { | |||
| 2584 | + | arg0 = args[0].(context.Context) | |||
| 2585 | + | } | |||
| 2586 | + | var arg1 int | |||
| 2587 | + | if args[1] != nil { | |||
| 2588 | + | arg1 = args[1].(int) | |||
| 2589 | + | } | |||
| 2590 | + | run( | |||
| 2591 | + | arg0, | |||
| 2592 | + | arg1, | |||
| 2593 | + | ) | |||
| 2594 | + | }) | |||
| 2595 | + | return _c | |||
| 2596 | + | } | |||
| 2597 | + | ||||
| 2598 | + | func (_c *MockHistoryRepository_GetRecentSince_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentSince_Call { | |||
| 2599 | + | _c.Call.Return(historyEntrys, err) | |||
| 2600 | + | return _c | |||
| 2601 | + | } | |||
| 2602 | + | ||||
| 2603 | + | func (_c *MockHistoryRepository_GetRecentSince_Call) RunAndReturn(run func(ctx context.Context, days int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentSince_Call { | |||
| 2604 | + | _c.Call.Return(run) | |||
| 2605 | + | return _c | |||
| 2606 | + | } | |||
| 2607 | + | ||||
| 2472 | // GetStats provides a mock function for the type MockHistoryRepository | 2608 | // GetStats provides a mock function for the type MockHistoryRepository | ||
| 2473 | func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | 2609 | func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) { | ||
| 2474 | ret := _mock.Called(ctx) | 2610 | ret := _mock.Called(ctx) | ||
M internal/infrastructure/config/defaults.go +2 -2
| @@ -7,7 +7,7 @@ const ( | |||
| 7 | 7 | defaultRetentionDays = 365 // 1 year | |
| 8 | 8 | ||
| 9 | 9 | // Dmenu defaults | |
| 10 | - | defaultMaxHistoryItems = 20 // items | |
| 10 | + | defaultMaxHistoryDays = 30 // days | |
| 11 | 11 | ||
| 12 | 12 | // Logging defaults | |
| 13 | 13 | defaultMaxLogAgeDays = 7 // days | |
| @@ -97,7 +97,7 @@ func DefaultConfig() *Config { | |||
| 97 | 97 | SearchShortcuts: GetDefaultSearchShortcuts(), | |
| 98 | 98 | DefaultSearchEngine: "https://duckduckgo.com/?q=%s", | |
| 99 | 99 | Dmenu: DmenuConfig{ | |
| 100 | - | MaxHistoryItems: defaultMaxHistoryItems, | |
| 100 | + | MaxHistoryDays: defaultMaxHistoryDays, | |
| 101 | 101 | ShowVisitCount: true, | |
| 102 | 102 | ShowLastVisited: true, | |
| 103 | 103 | HistoryPrefix: "🕒", | |
| @@ -7,7 +7,7 @@ const ( | |||||
| 7 | defaultRetentionDays = 365 // 1 year | 7 | defaultRetentionDays = 365 // 1 year | ||
| 8 | 8 | ||||
| 9 | // Dmenu defaults | 9 | // Dmenu defaults | ||
| 10 | - | defaultMaxHistoryItems = 20 // items | 10 | + | defaultMaxHistoryDays = 30 // days |
| 11 | 11 | ||||
| 12 | // Logging defaults | 12 | // Logging defaults | ||
| 13 | defaultMaxLogAgeDays = 7 // days | 13 | defaultMaxLogAgeDays = 7 // days | ||
| @@ -97,7 +97,7 @@ func DefaultConfig() *Config { | |||||
| 97 | SearchShortcuts: GetDefaultSearchShortcuts(), | 97 | SearchShortcuts: GetDefaultSearchShortcuts(), | ||
| 98 | DefaultSearchEngine: "https://duckduckgo.com/?q=%s", | 98 | DefaultSearchEngine: "https://duckduckgo.com/?q=%s", | ||
| 99 | Dmenu: DmenuConfig{ | 99 | Dmenu: DmenuConfig{ | ||
| 100 | - | MaxHistoryItems: defaultMaxHistoryItems, | 100 | + | MaxHistoryDays: defaultMaxHistoryDays, |
| 101 | ShowVisitCount: true, | 101 | ShowVisitCount: true, | ||
| 102 | ShowLastVisited: true, | 102 | ShowLastVisited: true, | ||
| 103 | HistoryPrefix: "🕒", | 103 | HistoryPrefix: "🕒", | ||
M internal/infrastructure/config/loader.go +1 -1
| @@ -348,7 +348,7 @@ func (m *Manager) setSearchDefaults(defaults *Config) { | |||
| 348 | 348 | } | |
| 349 | 349 | ||
| 350 | 350 | func (m *Manager) setDmenuDefaults(defaults *Config) { | |
| 351 | - | m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems) | |
| 351 | + | m.viper.SetDefault("dmenu.max_history_days", defaults.Dmenu.MaxHistoryDays) | |
| 352 | 352 | m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount) | |
| 353 | 353 | m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited) | |
| 354 | 354 | m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix) | |
| @@ -348,7 +348,7 @@ func (m *Manager) setSearchDefaults(defaults *Config) { | |||||
| 348 | } | 348 | } | ||
| 349 | 349 | ||||
| 350 | func (m *Manager) setDmenuDefaults(defaults *Config) { | 350 | func (m *Manager) setDmenuDefaults(defaults *Config) { | ||
| 351 | - | m.viper.SetDefault("dmenu.max_history_items", defaults.Dmenu.MaxHistoryItems) | 351 | + | m.viper.SetDefault("dmenu.max_history_days", defaults.Dmenu.MaxHistoryDays) |
| 352 | m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount) | 352 | m.viper.SetDefault("dmenu.show_visit_count", defaults.Dmenu.ShowVisitCount) | ||
| 353 | m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited) | 353 | m.viper.SetDefault("dmenu.show_last_visited", defaults.Dmenu.ShowLastVisited) | ||
| 354 | m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix) | 354 | m.viper.SetDefault("dmenu.history_prefix", defaults.Dmenu.HistoryPrefix) | ||
M internal/infrastructure/config/schema.go +1 -1
| @@ -286,7 +286,7 @@ func (c *Config) ShortcutURLs() map[string]string { | |||
| 286 | 286 | ||
| 287 | 287 | // DmenuConfig holds dmenu/rofi integration configuration. | |
| 288 | 288 | type DmenuConfig struct { | |
| 289 | - | MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items" toml:"max_history_items"` | |
| 289 | + | MaxHistoryDays int `mapstructure:"max_history_days" yaml:"max_history_days" toml:"max_history_days"` | |
| 290 | 290 | ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count" toml:"show_visit_count"` | |
| 291 | 291 | ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited" toml:"show_last_visited"` | |
| 292 | 292 | HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix" toml:"history_prefix"` | |
| @@ -286,7 +286,7 @@ func (c *Config) ShortcutURLs() map[string]string { | |||||
| 286 | 286 | ||||
| 287 | // DmenuConfig holds dmenu/rofi integration configuration. | 287 | // DmenuConfig holds dmenu/rofi integration configuration. | ||
| 288 | type DmenuConfig struct { | 288 | type DmenuConfig struct { | ||
| 289 | - | MaxHistoryItems int `mapstructure:"max_history_items" yaml:"max_history_items" toml:"max_history_items"` | 289 | + | MaxHistoryDays int `mapstructure:"max_history_days" yaml:"max_history_days" toml:"max_history_days"` |
| 290 | ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count" toml:"show_visit_count"` | 290 | ShowVisitCount bool `mapstructure:"show_visit_count" yaml:"show_visit_count" toml:"show_visit_count"` | ||
| 291 | ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited" toml:"show_last_visited"` | 291 | ShowLastVisited bool `mapstructure:"show_last_visited" yaml:"show_last_visited" toml:"show_last_visited"` | ||
| 292 | HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix" toml:"history_prefix"` | 292 | HistoryPrefix string `mapstructure:"history_prefix" yaml:"history_prefix" toml:"history_prefix"` | ||
M internal/infrastructure/config/schema_provider.go +3 -3
| @@ -264,10 +264,10 @@ func (*SchemaProvider) getSearchKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 264 | 264 | func (*SchemaProvider) getDmenuKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 265 | 265 | return []entity.ConfigKeyInfo{ | |
| 266 | 266 | { | |
| 267 | - | Key: "dmenu.max_history_items", | |
| 267 | + | Key: "dmenu.max_history_days", | |
| 268 | 268 | Type: "int", | |
| 269 | - | Default: fmt.Sprintf("%d", defaults.Dmenu.MaxHistoryItems), | |
| 270 | - | Description: "Maximum history items shown in dmenu", | |
| 269 | + | Default: fmt.Sprintf("%d", defaults.Dmenu.MaxHistoryDays), | |
| 270 | + | Description: "Number of days of history to show in dmenu (0 = all)", | |
| 271 | 271 | Range: ">=0", | |
| 272 | 272 | Section: SectionDmenu, | |
| 273 | 273 | }, | |
| @@ -264,10 +264,10 @@ func (*SchemaProvider) getSearchKeys(defaults *Config) []entity.ConfigKeyInfo { | |||||
| 264 | func (*SchemaProvider) getDmenuKeys(defaults *Config) []entity.ConfigKeyInfo { | 264 | func (*SchemaProvider) getDmenuKeys(defaults *Config) []entity.ConfigKeyInfo { | ||
| 265 | return []entity.ConfigKeyInfo{ | 265 | return []entity.ConfigKeyInfo{ | ||
| 266 | { | 266 | { | ||
| 267 | - | Key: "dmenu.max_history_items", | 267 | + | Key: "dmenu.max_history_days", |
| 268 | Type: "int", | 268 | Type: "int", | ||
| 269 | - | Default: fmt.Sprintf("%d", defaults.Dmenu.MaxHistoryItems), | 269 | + | Default: fmt.Sprintf("%d", defaults.Dmenu.MaxHistoryDays), |
| 270 | - | Description: "Maximum history items shown in dmenu", | 270 | + | Description: "Number of days of history to show in dmenu (0 = all)", |
| 271 | Range: ">=0", | 271 | Range: ">=0", | ||
| 272 | Section: SectionDmenu, | 272 | Section: SectionDmenu, | ||
| 273 | }, | 273 | }, | ||
M internal/infrastructure/config/validation.go +2 -2
| @@ -53,8 +53,8 @@ func validateHistory(config *Config) []string { | |||
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 55 | func validateDmenu(config *Config) []string { | |
| 56 | - | if config.Dmenu.MaxHistoryItems < 0 { | |
| 57 | - | return []string{"dmenu.max_history_items must be non-negative"} | |
| 56 | + | if config.Dmenu.MaxHistoryDays < 0 { | |
| 57 | + | return []string{"dmenu.max_history_days must be non-negative"} | |
| 58 | 58 | } | |
| 59 | 59 | return nil | |
| 60 | 60 | } | |
| @@ -53,8 +53,8 @@ func validateHistory(config *Config) []string { | |||||
| 53 | } | 53 | } | ||
| 54 | 54 | ||||
| 55 | func validateDmenu(config *Config) []string { | 55 | func validateDmenu(config *Config) []string { | ||
| 56 | - | if config.Dmenu.MaxHistoryItems < 0 { | 56 | + | if config.Dmenu.MaxHistoryDays < 0 { |
| 57 | - | return []string{"dmenu.max_history_items must be non-negative"} | 57 | + | return []string{"dmenu.max_history_days must be non-negative"} |
| 58 | } | 58 | } | ||
| 59 | return nil | 59 | return nil | ||
| 60 | } | 60 | } | ||
M internal/infrastructure/persistence/sqlite/history_repo.go +31 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "database/sql" | |
| 6 | 6 | "errors" | |
| 7 | + | "fmt" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -87,6 +88,36 @@ func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*enti | |||
| 87 | 88 | return entries, nil | |
| 88 | 89 | } | |
| 89 | 90 | ||
| 91 | + | func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| 92 | + | // Format: "-N days" for SQLite datetime modifier | |
| 93 | + | daysModifier := fmt.Sprintf("-%d days", days) | |
| 94 | + | rows, err := r.queries.GetRecentHistorySince(ctx, daysModifier) | |
| 95 | + | if err != nil { | |
| 96 | + | return nil, err | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | entries := make([]*entity.HistoryEntry, len(rows)) | |
| 100 | + | for i := range rows { | |
| 101 | + | entries[i] = historyFromRow(rows[i]) | |
| 102 | + | } | |
| 103 | + | return entries, nil | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| 107 | + | // Format: "-N days" for SQLite datetime modifier | |
| 108 | + | daysModifier := fmt.Sprintf("-%d days", days) | |
| 109 | + | rows, err := r.queries.GetMostVisited(ctx, daysModifier) | |
| 110 | + | if err != nil { | |
| 111 | + | return nil, err | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | entries := make([]*entity.HistoryEntry, len(rows)) | |
| 115 | + | for i := range rows { | |
| 116 | + | entries[i] = historyFromRow(rows[i]) | |
| 117 | + | } | |
| 118 | + | return entries, nil | |
| 119 | + | } | |
| 120 | + | ||
| 90 | 121 | func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error { | |
| 91 | 122 | return r.queries.IncrementVisitCount(ctx, url) | |
| 92 | 123 | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "database/sql" | 5 | "database/sql" | ||
| 6 | "errors" | 6 | "errors" | ||
| 7 | + | "fmt" | |||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| 9 | "github.com/bnema/dumber/internal/domain/entity" | 10 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -87,6 +88,36 @@ func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*enti | |||||
| 87 | return entries, nil | 88 | return entries, nil | ||
| 88 | } | 89 | } | ||
| 89 | 90 | ||||
| 91 | + | func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |||
| 92 | + | // Format: "-N days" for SQLite datetime modifier | |||
| 93 | + | daysModifier := fmt.Sprintf("-%d days", days) | |||
| 94 | + | rows, err := r.queries.GetRecentHistorySince(ctx, daysModifier) | |||
| 95 | + | if err != nil { | |||
| 96 | + | return nil, err | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | entries := make([]*entity.HistoryEntry, len(rows)) | |||
| 100 | + | for i := range rows { | |||
| 101 | + | entries[i] = historyFromRow(rows[i]) | |||
| 102 | + | } | |||
| 103 | + | return entries, nil | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |||
| 107 | + | // Format: "-N days" for SQLite datetime modifier | |||
| 108 | + | daysModifier := fmt.Sprintf("-%d days", days) | |||
| 109 | + | rows, err := r.queries.GetMostVisited(ctx, daysModifier) | |||
| 110 | + | if err != nil { | |||
| 111 | + | return nil, err | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | entries := make([]*entity.HistoryEntry, len(rows)) | |||
| 115 | + | for i := range rows { | |||
| 116 | + | entries[i] = historyFromRow(rows[i]) | |||
| 117 | + | } | |||
| 118 | + | return entries, nil | |||
| 119 | + | } | |||
| 120 | + | ||||
| 90 | func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error { | 121 | func (r *historyRepo) IncrementVisitCount(ctx context.Context, url string) error { | ||
| 91 | return r.queries.IncrementVisitCount(ctx, url) | 122 | return r.queries.IncrementVisitCount(ctx, url) | ||
| 92 | } | 123 | } | ||
A internal/infrastructure/persistence/sqlite/history_repo_test.go +226 -0
| @@ -0,0 +1,226 @@ | |||
| 1 | + | package sqlite_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 11 | + | "github.com/bnema/dumber/internal/logging" | |
| 12 | + | "github.com/stretchr/testify/assert" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func historyTestCtx() context.Context { | |
| 17 | + | logger := logging.NewFromConfigValues("debug", "console") | |
| 18 | + | return logging.WithContext(context.Background(), logger) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestHistoryRepository_GetRecentSince(t *testing.T) { | |
| 22 | + | ctx := historyTestCtx() | |
| 23 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 24 | + | ||
| 25 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 26 | + | require.NoError(t, err) | |
| 27 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 28 | + | ||
| 29 | + | repo := sqlite.NewHistoryRepository(db) | |
| 30 | + | ||
| 31 | + | // Save some history entries | |
| 32 | + | entries := []*entity.HistoryEntry{ | |
| 33 | + | {URL: "https://example.com", Title: "Example"}, | |
| 34 | + | {URL: "https://github.com", Title: "GitHub"}, | |
| 35 | + | {URL: "https://google.com", Title: "Google"}, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | for _, e := range entries { | |
| 39 | + | require.NoError(t, repo.Save(ctx, e)) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // Get recent since 30 days (all should be included) | |
| 43 | + | results, err := repo.GetRecentSince(ctx, 30) | |
| 44 | + | require.NoError(t, err) | |
| 45 | + | require.Len(t, results, 3) | |
| 46 | + | ||
| 47 | + | // Results should be sorted by last_visited DESC | |
| 48 | + | // Since they were all just inserted, order may vary | |
| 49 | + | urls := make(map[string]bool) | |
| 50 | + | for _, r := range results { | |
| 51 | + | urls[r.URL] = true | |
| 52 | + | } | |
| 53 | + | assert.True(t, urls["https://example.com"]) | |
| 54 | + | assert.True(t, urls["https://github.com"]) | |
| 55 | + | assert.True(t, urls["https://google.com"]) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestHistoryRepository_GetRecentSince_EmptyResult(t *testing.T) { | |
| 59 | + | ctx := historyTestCtx() | |
| 60 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 61 | + | ||
| 62 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 65 | + | ||
| 66 | + | repo := sqlite.NewHistoryRepository(db) | |
| 67 | + | ||
| 68 | + | // Get recent with no data | |
| 69 | + | results, err := repo.GetRecentSince(ctx, 30) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | assert.Empty(t, results) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestHistoryRepository_GetMostVisited(t *testing.T) { | |
| 75 | + | ctx := historyTestCtx() | |
| 76 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 77 | + | ||
| 78 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 81 | + | ||
| 82 | + | repo := sqlite.NewHistoryRepository(db) | |
| 83 | + | ||
| 84 | + | // Save entries with different visit counts | |
| 85 | + | entry1 := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"} | |
| 86 | + | entry2 := &entity.HistoryEntry{URL: "https://github.com", Title: "GitHub"} | |
| 87 | + | entry3 := &entity.HistoryEntry{URL: "https://google.com", Title: "Google"} | |
| 88 | + | ||
| 89 | + | require.NoError(t, repo.Save(ctx, entry1)) | |
| 90 | + | require.NoError(t, repo.Save(ctx, entry2)) | |
| 91 | + | require.NoError(t, repo.Save(ctx, entry3)) | |
| 92 | + | ||
| 93 | + | // Increment visit counts to create different popularities | |
| 94 | + | // github: 5 visits, google: 3 visits, example: 1 visit | |
| 95 | + | for i := 0; i < 4; i++ { | |
| 96 | + | require.NoError(t, repo.IncrementVisitCount(ctx, "https://github.com")) | |
| 97 | + | } | |
| 98 | + | for i := 0; i < 2; i++ { | |
| 99 | + | require.NoError(t, repo.IncrementVisitCount(ctx, "https://google.com")) | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // Get most visited | |
| 103 | + | results, err := repo.GetMostVisited(ctx, 30) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | require.Len(t, results, 3) | |
| 106 | + | ||
| 107 | + | // First result should be github (5 visits) | |
| 108 | + | assert.Equal(t, "https://github.com", results[0].URL) | |
| 109 | + | assert.Equal(t, int64(5), results[0].VisitCount) | |
| 110 | + | ||
| 111 | + | // Second should be google (3 visits) | |
| 112 | + | assert.Equal(t, "https://google.com", results[1].URL) | |
| 113 | + | assert.Equal(t, int64(3), results[1].VisitCount) | |
| 114 | + | ||
| 115 | + | // Third should be example (1 visit) | |
| 116 | + | assert.Equal(t, "https://example.com", results[2].URL) | |
| 117 | + | assert.Equal(t, int64(1), results[2].VisitCount) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestHistoryRepository_GetMostVisited_EmptyResult(t *testing.T) { | |
| 121 | + | ctx := historyTestCtx() | |
| 122 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 123 | + | ||
| 124 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 125 | + | require.NoError(t, err) | |
| 126 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 127 | + | ||
| 128 | + | repo := sqlite.NewHistoryRepository(db) | |
| 129 | + | ||
| 130 | + | // Get most visited with no data | |
| 131 | + | results, err := repo.GetMostVisited(ctx, 30) | |
| 132 | + | require.NoError(t, err) | |
| 133 | + | assert.Empty(t, results) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func TestHistoryRepository_GetRecentSince_RespectsTimeFilter(t *testing.T) { | |
| 137 | + | ctx := historyTestCtx() | |
| 138 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 139 | + | ||
| 140 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 141 | + | require.NoError(t, err) | |
| 142 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 143 | + | ||
| 144 | + | repo := sqlite.NewHistoryRepository(db) | |
| 145 | + | ||
| 146 | + | // Save an entry | |
| 147 | + | entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"} | |
| 148 | + | require.NoError(t, repo.Save(ctx, entry)) | |
| 149 | + | ||
| 150 | + | // Query with 1 day filter - should include recently added entry | |
| 151 | + | results, err := repo.GetRecentSince(ctx, 1) | |
| 152 | + | require.NoError(t, err) | |
| 153 | + | require.Len(t, results, 1) | |
| 154 | + | ||
| 155 | + | // Query with very large days filter - should still work | |
| 156 | + | results, err = repo.GetRecentSince(ctx, 365) | |
| 157 | + | require.NoError(t, err) | |
| 158 | + | require.Len(t, results, 1) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestHistoryRepository_CRUD(t *testing.T) { | |
| 162 | + | ctx := historyTestCtx() | |
| 163 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 164 | + | ||
| 165 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 166 | + | require.NoError(t, err) | |
| 167 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 168 | + | ||
| 169 | + | repo := sqlite.NewHistoryRepository(db) | |
| 170 | + | ||
| 171 | + | // Create | |
| 172 | + | entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"} | |
| 173 | + | require.NoError(t, repo.Save(ctx, entry)) | |
| 174 | + | ||
| 175 | + | // Read | |
| 176 | + | found, err := repo.FindByURL(ctx, "https://example.com") | |
| 177 | + | require.NoError(t, err) | |
| 178 | + | require.NotNil(t, found) | |
| 179 | + | assert.Equal(t, "https://example.com", found.URL) | |
| 180 | + | assert.Equal(t, "Example", found.Title) | |
| 181 | + | assert.Equal(t, int64(1), found.VisitCount) | |
| 182 | + | ||
| 183 | + | // Update (save again increments visit count) | |
| 184 | + | require.NoError(t, repo.Save(ctx, entry)) | |
| 185 | + | found2, err := repo.FindByURL(ctx, "https://example.com") | |
| 186 | + | require.NoError(t, err) | |
| 187 | + | assert.Equal(t, int64(2), found2.VisitCount) | |
| 188 | + | ||
| 189 | + | // Get recent | |
| 190 | + | recent, err := repo.GetRecent(ctx, 10, 0) | |
| 191 | + | require.NoError(t, err) | |
| 192 | + | require.Len(t, recent, 1) | |
| 193 | + | ||
| 194 | + | // Delete | |
| 195 | + | require.NoError(t, repo.Delete(ctx, found.ID)) | |
| 196 | + | ||
| 197 | + | // Verify deleted | |
| 198 | + | deleted, err := repo.FindByURL(ctx, "https://example.com") | |
| 199 | + | require.NoError(t, err) | |
| 200 | + | assert.Nil(t, deleted) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // Ensure the test uses an isolated timestamp for time-sensitive queries | |
| 204 | + | func TestHistoryRepository_TimeFilterIntegrity(t *testing.T) { | |
| 205 | + | ctx := historyTestCtx() | |
| 206 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |
| 207 | + | ||
| 208 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |
| 209 | + | require.NoError(t, err) | |
| 210 | + | t.Cleanup(func() { _ = db.Close() }) | |
| 211 | + | ||
| 212 | + | repo := sqlite.NewHistoryRepository(db) | |
| 213 | + | ||
| 214 | + | // Save entry | |
| 215 | + | entry := &entity.HistoryEntry{URL: "https://test.com", Title: "Test"} | |
| 216 | + | require.NoError(t, repo.Save(ctx, entry)) | |
| 217 | + | ||
| 218 | + | // Verify entry was saved with recent timestamp | |
| 219 | + | found, err := repo.FindByURL(ctx, "https://test.com") | |
| 220 | + | require.NoError(t, err) | |
| 221 | + | require.NotNil(t, found) | |
| 222 | + | ||
| 223 | + | // LastVisited should be within the last minute | |
| 224 | + | timeDiff := time.Since(found.LastVisited) | |
| 225 | + | assert.Less(t, timeDiff, time.Minute, "LastVisited should be recent, got %v ago", timeDiff) | |
| 226 | + | } | |
| @@ -0,0 +1,226 @@ | |||||
| 1 | + | package sqlite_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |||
| 11 | + | "github.com/bnema/dumber/internal/logging" | |||
| 12 | + | "github.com/stretchr/testify/assert" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func historyTestCtx() context.Context { | |||
| 17 | + | logger := logging.NewFromConfigValues("debug", "console") | |||
| 18 | + | return logging.WithContext(context.Background(), logger) | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func TestHistoryRepository_GetRecentSince(t *testing.T) { | |||
| 22 | + | ctx := historyTestCtx() | |||
| 23 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 24 | + | ||||
| 25 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 26 | + | require.NoError(t, err) | |||
| 27 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 28 | + | ||||
| 29 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 30 | + | ||||
| 31 | + | // Save some history entries | |||
| 32 | + | entries := []*entity.HistoryEntry{ | |||
| 33 | + | {URL: "https://example.com", Title: "Example"}, | |||
| 34 | + | {URL: "https://github.com", Title: "GitHub"}, | |||
| 35 | + | {URL: "https://google.com", Title: "Google"}, | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | for _, e := range entries { | |||
| 39 | + | require.NoError(t, repo.Save(ctx, e)) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // Get recent since 30 days (all should be included) | |||
| 43 | + | results, err := repo.GetRecentSince(ctx, 30) | |||
| 44 | + | require.NoError(t, err) | |||
| 45 | + | require.Len(t, results, 3) | |||
| 46 | + | ||||
| 47 | + | // Results should be sorted by last_visited DESC | |||
| 48 | + | // Since they were all just inserted, order may vary | |||
| 49 | + | urls := make(map[string]bool) | |||
| 50 | + | for _, r := range results { | |||
| 51 | + | urls[r.URL] = true | |||
| 52 | + | } | |||
| 53 | + | assert.True(t, urls["https://example.com"]) | |||
| 54 | + | assert.True(t, urls["https://github.com"]) | |||
| 55 | + | assert.True(t, urls["https://google.com"]) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestHistoryRepository_GetRecentSince_EmptyResult(t *testing.T) { | |||
| 59 | + | ctx := historyTestCtx() | |||
| 60 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 61 | + | ||||
| 62 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 65 | + | ||||
| 66 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 67 | + | ||||
| 68 | + | // Get recent with no data | |||
| 69 | + | results, err := repo.GetRecentSince(ctx, 30) | |||
| 70 | + | require.NoError(t, err) | |||
| 71 | + | assert.Empty(t, results) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestHistoryRepository_GetMostVisited(t *testing.T) { | |||
| 75 | + | ctx := historyTestCtx() | |||
| 76 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 77 | + | ||||
| 78 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 79 | + | require.NoError(t, err) | |||
| 80 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 81 | + | ||||
| 82 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 83 | + | ||||
| 84 | + | // Save entries with different visit counts | |||
| 85 | + | entry1 := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"} | |||
| 86 | + | entry2 := &entity.HistoryEntry{URL: "https://github.com", Title: "GitHub"} | |||
| 87 | + | entry3 := &entity.HistoryEntry{URL: "https://google.com", Title: "Google"} | |||
| 88 | + | ||||
| 89 | + | require.NoError(t, repo.Save(ctx, entry1)) | |||
| 90 | + | require.NoError(t, repo.Save(ctx, entry2)) | |||
| 91 | + | require.NoError(t, repo.Save(ctx, entry3)) | |||
| 92 | + | ||||
| 93 | + | // Increment visit counts to create different popularities | |||
| 94 | + | // github: 5 visits, google: 3 visits, example: 1 visit | |||
| 95 | + | for i := 0; i < 4; i++ { | |||
| 96 | + | require.NoError(t, repo.IncrementVisitCount(ctx, "https://github.com")) | |||
| 97 | + | } | |||
| 98 | + | for i := 0; i < 2; i++ { | |||
| 99 | + | require.NoError(t, repo.IncrementVisitCount(ctx, "https://google.com")) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // Get most visited | |||
| 103 | + | results, err := repo.GetMostVisited(ctx, 30) | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | require.Len(t, results, 3) | |||
| 106 | + | ||||
| 107 | + | // First result should be github (5 visits) | |||
| 108 | + | assert.Equal(t, "https://github.com", results[0].URL) | |||
| 109 | + | assert.Equal(t, int64(5), results[0].VisitCount) | |||
| 110 | + | ||||
| 111 | + | // Second should be google (3 visits) | |||
| 112 | + | assert.Equal(t, "https://google.com", results[1].URL) | |||
| 113 | + | assert.Equal(t, int64(3), results[1].VisitCount) | |||
| 114 | + | ||||
| 115 | + | // Third should be example (1 visit) | |||
| 116 | + | assert.Equal(t, "https://example.com", results[2].URL) | |||
| 117 | + | assert.Equal(t, int64(1), results[2].VisitCount) | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestHistoryRepository_GetMostVisited_EmptyResult(t *testing.T) { | |||
| 121 | + | ctx := historyTestCtx() | |||
| 122 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 123 | + | ||||
| 124 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 125 | + | require.NoError(t, err) | |||
| 126 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 127 | + | ||||
| 128 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 129 | + | ||||
| 130 | + | // Get most visited with no data | |||
| 131 | + | results, err := repo.GetMostVisited(ctx, 30) | |||
| 132 | + | require.NoError(t, err) | |||
| 133 | + | assert.Empty(t, results) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestHistoryRepository_GetRecentSince_RespectsTimeFilter(t *testing.T) { | |||
| 137 | + | ctx := historyTestCtx() | |||
| 138 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 139 | + | ||||
| 140 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 141 | + | require.NoError(t, err) | |||
| 142 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 143 | + | ||||
| 144 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 145 | + | ||||
| 146 | + | // Save an entry | |||
| 147 | + | entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"} | |||
| 148 | + | require.NoError(t, repo.Save(ctx, entry)) | |||
| 149 | + | ||||
| 150 | + | // Query with 1 day filter - should include recently added entry | |||
| 151 | + | results, err := repo.GetRecentSince(ctx, 1) | |||
| 152 | + | require.NoError(t, err) | |||
| 153 | + | require.Len(t, results, 1) | |||
| 154 | + | ||||
| 155 | + | // Query with very large days filter - should still work | |||
| 156 | + | results, err = repo.GetRecentSince(ctx, 365) | |||
| 157 | + | require.NoError(t, err) | |||
| 158 | + | require.Len(t, results, 1) | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func TestHistoryRepository_CRUD(t *testing.T) { | |||
| 162 | + | ctx := historyTestCtx() | |||
| 163 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 164 | + | ||||
| 165 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 166 | + | require.NoError(t, err) | |||
| 167 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 168 | + | ||||
| 169 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 170 | + | ||||
| 171 | + | // Create | |||
| 172 | + | entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"} | |||
| 173 | + | require.NoError(t, repo.Save(ctx, entry)) | |||
| 174 | + | ||||
| 175 | + | // Read | |||
| 176 | + | found, err := repo.FindByURL(ctx, "https://example.com") | |||
| 177 | + | require.NoError(t, err) | |||
| 178 | + | require.NotNil(t, found) | |||
| 179 | + | assert.Equal(t, "https://example.com", found.URL) | |||
| 180 | + | assert.Equal(t, "Example", found.Title) | |||
| 181 | + | assert.Equal(t, int64(1), found.VisitCount) | |||
| 182 | + | ||||
| 183 | + | // Update (save again increments visit count) | |||
| 184 | + | require.NoError(t, repo.Save(ctx, entry)) | |||
| 185 | + | found2, err := repo.FindByURL(ctx, "https://example.com") | |||
| 186 | + | require.NoError(t, err) | |||
| 187 | + | assert.Equal(t, int64(2), found2.VisitCount) | |||
| 188 | + | ||||
| 189 | + | // Get recent | |||
| 190 | + | recent, err := repo.GetRecent(ctx, 10, 0) | |||
| 191 | + | require.NoError(t, err) | |||
| 192 | + | require.Len(t, recent, 1) | |||
| 193 | + | ||||
| 194 | + | // Delete | |||
| 195 | + | require.NoError(t, repo.Delete(ctx, found.ID)) | |||
| 196 | + | ||||
| 197 | + | // Verify deleted | |||
| 198 | + | deleted, err := repo.FindByURL(ctx, "https://example.com") | |||
| 199 | + | require.NoError(t, err) | |||
| 200 | + | assert.Nil(t, deleted) | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | // Ensure the test uses an isolated timestamp for time-sensitive queries | |||
| 204 | + | func TestHistoryRepository_TimeFilterIntegrity(t *testing.T) { | |||
| 205 | + | ctx := historyTestCtx() | |||
| 206 | + | dbPath := filepath.Join(t.TempDir(), "dumber.db") | |||
| 207 | + | ||||
| 208 | + | db, err := sqlite.NewConnection(ctx, dbPath) | |||
| 209 | + | require.NoError(t, err) | |||
| 210 | + | t.Cleanup(func() { _ = db.Close() }) | |||
| 211 | + | ||||
| 212 | + | repo := sqlite.NewHistoryRepository(db) | |||
| 213 | + | ||||
| 214 | + | // Save entry | |||
| 215 | + | entry := &entity.HistoryEntry{URL: "https://test.com", Title: "Test"} | |||
| 216 | + | require.NoError(t, repo.Save(ctx, entry)) | |||
| 217 | + | ||||
| 218 | + | // Verify entry was saved with recent timestamp | |||
| 219 | + | found, err := repo.FindByURL(ctx, "https://test.com") | |||
| 220 | + | require.NoError(t, err) | |||
| 221 | + | require.NotNil(t, found) | |||
| 222 | + | ||||
| 223 | + | // LastVisited should be within the last minute | |||
| 224 | + | timeDiff := time.Since(found.LastVisited) | |||
| 225 | + | assert.Less(t, timeDiff, time.Minute, "LastVisited should be recent, got %v ago", timeDiff) | |||
| 226 | + | } | |||
M internal/infrastructure/persistence/sqlite/lazy_repos.go +14 -0
| @@ -79,6 +79,20 @@ func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int | |||
| 79 | 79 | return r.repo.GetRecent(ctx, limit, offset) | |
| 80 | 80 | } | |
| 81 | 81 | ||
| 82 | + | func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| 83 | + | if err := r.init(ctx); err != nil { | |
| 84 | + | return nil, err | |
| 85 | + | } | |
| 86 | + | return r.repo.GetRecentSince(ctx, days) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (r *LazyHistoryRepository) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |
| 90 | + | if err := r.init(ctx); err != nil { | |
| 91 | + | return nil, err | |
| 92 | + | } | |
| 93 | + | return r.repo.GetMostVisited(ctx, days) | |
| 94 | + | } | |
| 95 | + | ||
| 82 | 96 | func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error { | |
| 83 | 97 | if err := r.init(ctx); err != nil { | |
| 84 | 98 | return err | |
| @@ -79,6 +79,20 @@ func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int | |||||
| 79 | return r.repo.GetRecent(ctx, limit, offset) | 79 | return r.repo.GetRecent(ctx, limit, offset) | ||
| 80 | } | 80 | } | ||
| 81 | 81 | ||||
| 82 | + | func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |||
| 83 | + | if err := r.init(ctx); err != nil { | |||
| 84 | + | return nil, err | |||
| 85 | + | } | |||
| 86 | + | return r.repo.GetRecentSince(ctx, days) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (r *LazyHistoryRepository) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) { | |||
| 90 | + | if err := r.init(ctx); err != nil { | |||
| 91 | + | return nil, err | |||
| 92 | + | } | |||
| 93 | + | return r.repo.GetMostVisited(ctx, days) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 82 | func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error { | 96 | func (r *LazyHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error { | ||
| 83 | if err := r.init(ctx); err != nil { | 97 | if err := r.init(ctx); err != nil { | ||
| 84 | return err | 98 | return err | ||
M internal/infrastructure/persistence/sqlite/queries/history.sql +10 -0
| @@ -56,3 +56,13 @@ JOIN history h ON fts.rowid = h.id | |||
| 56 | 56 | WHERE fts.url MATCH ? | |
| 57 | 57 | ORDER BY bm25(history_fts) | |
| 58 | 58 | LIMIT ?; | |
| 59 | + | ||
| 60 | + | -- name: GetRecentHistorySince :many | |
| 61 | + | SELECT * FROM history | |
| 62 | + | WHERE last_visited >= datetime('now', ?) | |
| 63 | + | ORDER BY last_visited DESC; | |
| 64 | + | ||
| 65 | + | -- name: GetMostVisited :many | |
| 66 | + | SELECT * FROM history | |
| 67 | + | WHERE last_visited >= datetime('now', ?) | |
| 68 | + | ORDER BY visit_count DESC, last_visited DESC; | |
| @@ -56,3 +56,13 @@ JOIN history h ON fts.rowid = h.id | |||||
| 56 | WHERE fts.url MATCH ? | 56 | WHERE fts.url MATCH ? | ||
| 57 | ORDER BY bm25(history_fts) | 57 | ORDER BY bm25(history_fts) | ||
| 58 | LIMIT ?; | 58 | LIMIT ?; | ||
| 59 | + | ||||
| 60 | + | -- name: GetRecentHistorySince :many | |||
| 61 | + | SELECT * FROM history | |||
| 62 | + | WHERE last_visited >= datetime('now', ?) | |||
| 63 | + | ORDER BY last_visited DESC; | |||
| 64 | + | ||||
| 65 | + | -- name: GetMostVisited :many | |||
| 66 | + | SELECT * FROM history | |||
| 67 | + | WHERE last_visited >= datetime('now', ?) | |||
| 68 | + | ORDER BY visit_count DESC, last_visited DESC; | |||
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +74 -0
| @@ -198,6 +198,43 @@ func (q *Queries) GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistrib | |||
| 198 | 198 | return items, nil | |
| 199 | 199 | } | |
| 200 | 200 | ||
| 201 | + | const GetMostVisited = `-- name: GetMostVisited :many | |
| 202 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | |
| 203 | + | WHERE last_visited >= datetime('now', ?) | |
| 204 | + | ORDER BY visit_count DESC, last_visited DESC | |
| 205 | + | ` | |
| 206 | + | ||
| 207 | + | func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error) { | |
| 208 | + | rows, err := q.db.QueryContext(ctx, GetMostVisited, datetime) | |
| 209 | + | if err != nil { | |
| 210 | + | return nil, err | |
| 211 | + | } | |
| 212 | + | defer rows.Close() | |
| 213 | + | items := []History{} | |
| 214 | + | for rows.Next() { | |
| 215 | + | var i History | |
| 216 | + | if err := rows.Scan( | |
| 217 | + | &i.ID, | |
| 218 | + | &i.Url, | |
| 219 | + | &i.Title, | |
| 220 | + | &i.FaviconUrl, | |
| 221 | + | &i.VisitCount, | |
| 222 | + | &i.LastVisited, | |
| 223 | + | &i.CreatedAt, | |
| 224 | + | ); err != nil { | |
| 225 | + | return nil, err | |
| 226 | + | } | |
| 227 | + | items = append(items, i) | |
| 228 | + | } | |
| 229 | + | if err := rows.Close(); err != nil { | |
| 230 | + | return nil, err | |
| 231 | + | } | |
| 232 | + | if err := rows.Err(); err != nil { | |
| 233 | + | return nil, err | |
| 234 | + | } | |
| 235 | + | return items, nil | |
| 236 | + | } | |
| 237 | + | ||
| 201 | 238 | const GetRecentHistory = `-- name: GetRecentHistory :many | |
| 202 | 239 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ? | |
| 203 | 240 | ` | |
| @@ -238,6 +275,43 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara | |||
| 238 | 275 | return items, nil | |
| 239 | 276 | } | |
| 240 | 277 | ||
| 278 | + | const GetRecentHistorySince = `-- name: GetRecentHistorySince :many | |
| 279 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | |
| 280 | + | WHERE last_visited >= datetime('now', ?) | |
| 281 | + | ORDER BY last_visited DESC | |
| 282 | + | ` | |
| 283 | + | ||
| 284 | + | func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) { | |
| 285 | + | rows, err := q.db.QueryContext(ctx, GetRecentHistorySince, datetime) | |
| 286 | + | if err != nil { | |
| 287 | + | return nil, err | |
| 288 | + | } | |
| 289 | + | defer rows.Close() | |
| 290 | + | items := []History{} | |
| 291 | + | for rows.Next() { | |
| 292 | + | var i History | |
| 293 | + | if err := rows.Scan( | |
| 294 | + | &i.ID, | |
| 295 | + | &i.Url, | |
| 296 | + | &i.Title, | |
| 297 | + | &i.FaviconUrl, | |
| 298 | + | &i.VisitCount, | |
| 299 | + | &i.LastVisited, | |
| 300 | + | &i.CreatedAt, | |
| 301 | + | ); err != nil { | |
| 302 | + | return nil, err | |
| 303 | + | } | |
| 304 | + | items = append(items, i) | |
| 305 | + | } | |
| 306 | + | if err := rows.Close(); err != nil { | |
| 307 | + | return nil, err | |
| 308 | + | } | |
| 309 | + | if err := rows.Err(); err != nil { | |
| 310 | + | return nil, err | |
| 311 | + | } | |
| 312 | + | return items, nil | |
| 313 | + | } | |
| 314 | + | ||
| 241 | 315 | const IncrementVisitCount = `-- name: IncrementVisitCount :exec | |
| 242 | 316 | UPDATE history | |
| 243 | 317 | SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP | |
| @@ -198,6 +198,43 @@ func (q *Queries) GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistrib | |||||
| 198 | return items, nil | 198 | return items, nil | ||
| 199 | } | 199 | } | ||
| 200 | 200 | ||||
| 201 | + | const GetMostVisited = `-- name: GetMostVisited :many | |||
| 202 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | |||
| 203 | + | WHERE last_visited >= datetime('now', ?) | |||
| 204 | + | ORDER BY visit_count DESC, last_visited DESC | |||
| 205 | + | ` | |||
| 206 | + | ||||
| 207 | + | func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error) { | |||
| 208 | + | rows, err := q.db.QueryContext(ctx, GetMostVisited, datetime) | |||
| 209 | + | if err != nil { | |||
| 210 | + | return nil, err | |||
| 211 | + | } | |||
| 212 | + | defer rows.Close() | |||
| 213 | + | items := []History{} | |||
| 214 | + | for rows.Next() { | |||
| 215 | + | var i History | |||
| 216 | + | if err := rows.Scan( | |||
| 217 | + | &i.ID, | |||
| 218 | + | &i.Url, | |||
| 219 | + | &i.Title, | |||
| 220 | + | &i.FaviconUrl, | |||
| 221 | + | &i.VisitCount, | |||
| 222 | + | &i.LastVisited, | |||
| 223 | + | &i.CreatedAt, | |||
| 224 | + | ); err != nil { | |||
| 225 | + | return nil, err | |||
| 226 | + | } | |||
| 227 | + | items = append(items, i) | |||
| 228 | + | } | |||
| 229 | + | if err := rows.Close(); err != nil { | |||
| 230 | + | return nil, err | |||
| 231 | + | } | |||
| 232 | + | if err := rows.Err(); err != nil { | |||
| 233 | + | return nil, err | |||
| 234 | + | } | |||
| 235 | + | return items, nil | |||
| 236 | + | } | |||
| 237 | + | ||||
| 201 | const GetRecentHistory = `-- name: GetRecentHistory :many | 238 | const GetRecentHistory = `-- name: GetRecentHistory :many | ||
| 202 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ? | 239 | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ? | ||
| 203 | ` | 240 | ` | ||
| @@ -238,6 +275,43 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara | |||||
| 238 | return items, nil | 275 | return items, nil | ||
| 239 | } | 276 | } | ||
| 240 | 277 | ||||
| 278 | + | const GetRecentHistorySince = `-- name: GetRecentHistorySince :many | |||
| 279 | + | SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history | |||
| 280 | + | WHERE last_visited >= datetime('now', ?) | |||
| 281 | + | ORDER BY last_visited DESC | |||
| 282 | + | ` | |||
| 283 | + | ||||
| 284 | + | func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) { | |||
| 285 | + | rows, err := q.db.QueryContext(ctx, GetRecentHistorySince, datetime) | |||
| 286 | + | if err != nil { | |||
| 287 | + | return nil, err | |||
| 288 | + | } | |||
| 289 | + | defer rows.Close() | |||
| 290 | + | items := []History{} | |||
| 291 | + | for rows.Next() { | |||
| 292 | + | var i History | |||
| 293 | + | if err := rows.Scan( | |||
| 294 | + | &i.ID, | |||
| 295 | + | &i.Url, | |||
| 296 | + | &i.Title, | |||
| 297 | + | &i.FaviconUrl, | |||
| 298 | + | &i.VisitCount, | |||
| 299 | + | &i.LastVisited, | |||
| 300 | + | &i.CreatedAt, | |||
| 301 | + | ); err != nil { | |||
| 302 | + | return nil, err | |||
| 303 | + | } | |||
| 304 | + | items = append(items, i) | |||
| 305 | + | } | |||
| 306 | + | if err := rows.Close(); err != nil { | |||
| 307 | + | return nil, err | |||
| 308 | + | } | |||
| 309 | + | if err := rows.Err(); err != nil { | |||
| 310 | + | return nil, err | |||
| 311 | + | } | |||
| 312 | + | return items, nil | |||
| 313 | + | } | |||
| 314 | + | ||||
| 241 | const IncrementVisitCount = `-- name: IncrementVisitCount :exec | 315 | const IncrementVisitCount = `-- name: IncrementVisitCount :exec | ||
| 242 | UPDATE history | 316 | UPDATE history | ||
| 243 | SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP | 317 | SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP | ||
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +2 -0
| @@ -47,7 +47,9 @@ type Querier interface { | |||
| 47 | 47 | GetHistoryByURL(ctx context.Context, url string) (History, error) | |
| 48 | 48 | GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error) | |
| 49 | 49 | GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error) | |
| 50 | + | GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error) | |
| 50 | 51 | GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error) | |
| 52 | + | GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) | |
| 51 | 53 | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | |
| 52 | 54 | GetRootFolders(ctx context.Context) ([]FavoriteFolder, error) | |
| 53 | 55 | GetSessionByID(ctx context.Context, id string) (Session, error) | |
| @@ -47,7 +47,9 @@ type Querier interface { | |||||
| 47 | GetHistoryByURL(ctx context.Context, url string) (History, error) | 47 | GetHistoryByURL(ctx context.Context, url string) (History, error) | ||
| 48 | GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error) | 48 | GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, error) | ||
| 49 | GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error) | 49 | GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistributionRow, error) | ||
| 50 | + | GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error) | |||
| 50 | GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error) | 51 | GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error) | ||
| 52 | + | GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error) | |||
| 51 | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | 53 | GetRecentSessions(ctx context.Context, limit int64) ([]Session, error) | ||
| 52 | GetRootFolders(ctx context.Context) ([]FavoriteFolder, error) | 54 | GetRootFolders(ctx context.Context) ([]FavoriteFolder, error) | ||
| 53 | GetSessionByID(ctx context.Context, id string) (Session, error) | 55 | GetSessionByID(ctx context.Context, id string) (Session, error) | ||