diff --git a/internal/digest/date_grouper.go b/internal/digest/date_grouper.go new file mode 100644 index 0000000..752ae2f --- /dev/null +++ b/internal/digest/date_grouper.go @@ -0,0 +1,34 @@ +package digest + +import ( + "miniflux-digest/internal/models" + "sort" + "time" +) + +func GroupByDate(entries []*models.Entry) []*primaryGroup { + groups := make(map[string]*primaryGroup) + + for _, entry := range entries { + dateKey := entry.Date.Format("2006-01-02") + if _, ok := groups[dateKey]; !ok { + groups[dateKey] = &primaryGroup{ + Title: entry.Date.Format("Jan 2, 2006"), + } + } + groups[dateKey].Entries = append(groups[dateKey].Entries, entry) + } + + var result []*primaryGroup + for _, pg := range groups { + result = append(result, pg) + } + + sort.Slice(result, func(i, j int) bool { + iDate, _ := time.Parse("Jan 2, 2006", result[i].Title) + jDate, _ := time.Parse("Jan 2, 2006", result[j].Title) + return iDate.Before(jDate) // Oldest dates first + }) + + return result +} diff --git a/internal/digest/date_grouper_test.go b/internal/digest/date_grouper_test.go new file mode 100644 index 0000000..2136648 --- /dev/null +++ b/internal/digest/date_grouper_test.go @@ -0,0 +1,23 @@ +package digest + +import ( + "miniflux-digest/internal/testutil" + "testing" +) + +func TestGroupByDate(t *testing.T) { + entries := testutil.CreateMockEntries(0) + groups := GroupByDate(entries) + + if len(groups) != 3 { + t.Fatalf("Expected 3 date groups, got %d", len(groups)) + } + + if groups[0].Title != "Jan 1, 2024" { + t.Errorf("Expected first group to be Jan 1, 2024, got %s", groups[0].Title) + } + + if len(groups[0].Entries) != 3 { + t.Errorf("Expected 3 entries in first group, got %d", len(groups[0].Entries)) + } +} diff --git a/internal/digest/day_grouper.go b/internal/digest/day_grouper.go index bded7b2..6769f50 100644 --- a/internal/digest/day_grouper.go +++ b/internal/digest/day_grouper.go @@ -1,16 +1,19 @@ package digest import ( - "fmt" + "miniflux-digest/internal/models" "miniflux-digest/internal/utils" "sort" "time" ) -type DayGrouper struct{} +const ( + DayGroupLayout = "2006-01-02" + DayGroupTitleLayout = "Jan 2, 2006" +) -func (g *DayGrouper) GroupEntries(pgs []*primaryGroup) ([]*models.PrimaryGroupDigestData, *string) { +func SubGroupByDay(pgs []*primaryGroup) ([]*models.PrimaryGroupDigestData, *string) { var allPrimaryGroups []*models.PrimaryGroupDigestData for _, pg := range pgs { @@ -50,9 +53,7 @@ func (g *DayGrouper) GroupEntries(pgs []*primaryGroup) ([]*models.PrimaryGroupDi }) } - for _, seg := range sortedEntryGroups { - seg.Title = fmt.Sprintf("%s - %s", pg.Title, seg.Title) - } + allPrimaryGroups = append(allPrimaryGroups, &models.PrimaryGroupDigestData{ ID: pg.ID, diff --git a/internal/digest/digest.go b/internal/digest/digest.go index 7018ccd..68bb05c 100644 --- a/internal/digest/digest.go +++ b/internal/digest/digest.go @@ -7,11 +7,7 @@ import ( "time" ) -type primaryGroup struct { - ID int64 - Title string - Entries []*models.Entry -} + func GroupEntries(entries []*models.Entry, groupBy string) []*primaryGroup { groups := make(map[int64]*primaryGroup) @@ -62,29 +58,43 @@ func (s *digestServiceImpl) BuildDigestData(entries []*models.Entry, icons map[i iconsSlice = append(iconsSlice, icon) } - var groupBy, subGroupBy string + var allPrimaryGroups []*models.PrimaryGroupDigestData + var overallDigestSummary *string + switch view { case "date": - groupBy = "category" - subGroupBy = "date" + primaryGroups := GroupByDate(entries) + allPrimaryGroups, overallDigestSummary = SubGroupByFeed(primaryGroups) case "category": - groupBy = "category" - subGroupBy = "feed" + primaryGroups := GroupEntries(entries, "category") + allPrimaryGroups, overallDigestSummary = SubGroupByFeed(primaryGroups) + sort.Slice(allPrimaryGroups, func(i, j int) bool { + return allPrimaryGroups[i].Title < allPrimaryGroups[j].Title + }) case "ai": - groupBy = "category" - subGroupBy = "ai" - default: // also "category" - groupBy = "category" - subGroupBy = "feed" + primaryGroups := GroupEntries(entries, "category") + allPrimaryGroups, overallDigestSummary = SubGroupByAI(primaryGroups, s.llmService) + sort.Slice(allPrimaryGroups, func(i, j int) bool { + return allPrimaryGroups[i].Title < allPrimaryGroups[j].Title + }) + default: // category + primaryGroups := GroupEntries(entries, "category") + allPrimaryGroups, overallDigestSummary = SubGroupByFeed(primaryGroups) + sort.Slice(allPrimaryGroups, func(i, j int) bool { + return allPrimaryGroups[i].Title < allPrimaryGroups[j].Title + }) } - primaryGroups := GroupEntries(entries, groupBy) - grouper := NewSubGrouper(subGroupBy, s.llmService) - allPrimaryGroups, overallDigestSummary := grouper.GroupEntries(primaryGroups) - - sort.Slice(allPrimaryGroups, func(i, j int) bool { - return allPrimaryGroups[i].Title < allPrimaryGroups[j].Title - }) + // Sort entries by date for date and category views + if view == "date" || view == "category" { + for _, pg := range allPrimaryGroups { + for _, sg := range pg.SubGroups { + sort.Slice(sg.Entries, func(i, j int) bool { + return sg.Entries[i].Date.Before(sg.Entries[j].Date) + }) + } + } + } for _, primaryGroup := range allPrimaryGroups { primaryGroupTotalEntries := 0 diff --git a/internal/digest/digest_test.go b/internal/digest/digest_test.go index 6c51a44..e8756a3 100644 --- a/internal/digest/digest_test.go +++ b/internal/digest/digest_test.go @@ -5,6 +5,7 @@ import ( "errors" "miniflux-digest/internal/models" "miniflux-digest/internal/testutil" + "sort" "testing" "google.golang.org/genai" @@ -51,8 +52,7 @@ func TestDayGrouper_GroupEntries(t *testing.T) { entries := testutil.CreateMockEntries(0) primaryGroupsMap := GroupEntries(entries, "category") - grouper := &DayGrouper{} - groups, summary := grouper.GroupEntries(primaryGroupsMap) + groups, summary := SubGroupByDay(primaryGroupsMap) if summary != nil { t.Errorf("Expected an empty summary for DayGrouper, got %q", *summary) @@ -67,7 +67,7 @@ func TestDayGrouper_GroupEntries(t *testing.T) { t.Fatalf("Incorrect sub-groups for Category A: %+v", catAGroup) } - subGroup := findSubGroup(catAGroup, "Category A - Jan 2, 2024") + subGroup := findSubGroup(catAGroup, "Jan 2, 2024") if subGroup == nil || len(subGroup.Entries) != 4 { t.Fatalf("Incorrect entries for sub-group: %+v", subGroup) } @@ -77,8 +77,7 @@ func TestFeedGrouper_GroupEntries(t *testing.T) { entries := testutil.CreateMockEntries(0) primaryGroupsMap := GroupEntries(entries, "category") - grouper := &FeedGrouper{} - groups, summary := grouper.GroupEntries(primaryGroupsMap) + groups, summary := SubGroupByFeed(primaryGroupsMap) if summary != nil { t.Errorf("Expected an empty summary for FeedGrouper, got %q", *summary) @@ -119,8 +118,7 @@ func TestLLMGrouper_GroupEntries(t *testing.T) { }, } - grouper := &LLMGrouper{LLMService: mockLLM} - groups, summary := grouper.GroupEntries(primaryGroupsMap) + groups, summary := SubGroupByAI(primaryGroupsMap, mockLLM) if summary == nil || *summary != "This is a summary of all entries." { t.Errorf("Expected summary 'This is a summary of all entries.', got %q", *summary) @@ -181,14 +179,21 @@ func TestDigestService_BuildDigestData(t *testing.T) { if len(overviewData.PrimaryGroups) != 3 { t.Errorf("Expected 3 primary groups, got %d", len(overviewData.PrimaryGroups)) } - // Check for date-based sub-grouping - catAGroup := findPrimaryGroup(overviewData.PrimaryGroups, "Category A") - if catAGroup == nil || len(catAGroup.SubGroups) != 1 { - t.Fatalf("Incorrect sub-groups for Category A: %+v", catAGroup) + // Check for date-based primary grouping + dateGroup := findPrimaryGroup(overviewData.PrimaryGroups, "Jan 1, 2024") + if dateGroup == nil { + t.Fatalf("Did not find expected primary group 'Jan 1, 2024'") } - subGroup := findSubGroup(catAGroup, "Category A - Jan 2, 2024") - if subGroup == nil { - t.Fatalf("Did not find expected sub-group 'Category A - Jan 2, 2024'") + // Check for feed-based sub-grouping + if len(dateGroup.SubGroups) != 2 { + t.Fatalf("Incorrect sub-groups for Jan 1, 2024: %+v", dateGroup) + } + // Check that entries are sorted by date + firstSubGroup := dateGroup.SubGroups[0] + if !sort.SliceIsSorted(firstSubGroup.Entries, func(i, j int) bool { + return firstSubGroup.Entries[i].Date.Before(firstSubGroup.Entries[j].Date) + }) { + t.Errorf("Entries in sub-group are not sorted by date") } }) @@ -205,6 +210,13 @@ func TestDigestService_BuildDigestData(t *testing.T) { if catAGroup == nil || len(catAGroup.SubGroups) != 3 { t.Fatalf("Incorrect sub-groups for Category A: %+v", catAGroup) } + // Check that entries are sorted by date + firstSubGroup := catAGroup.SubGroups[0] + if !sort.SliceIsSorted(firstSubGroup.Entries, func(i, j int) bool { + return firstSubGroup.Entries[i].Date.Before(firstSubGroup.Entries[j].Date) + }) { + t.Errorf("Entries in sub-group are not sorted by date") + } }) t.Run("view=ai", func(t *testing.T) { diff --git a/internal/digest/feed_grouper.go b/internal/digest/feed_grouper.go index 7d9a6eb..5a61d5e 100644 --- a/internal/digest/feed_grouper.go +++ b/internal/digest/feed_grouper.go @@ -1,15 +1,13 @@ package digest import ( - "fmt" + "miniflux-digest/internal/models" "miniflux-digest/internal/utils" "sort" ) -type FeedGrouper struct{} - -func (g *FeedGrouper) GroupEntries(pgs []*primaryGroup) ([]*models.PrimaryGroupDigestData, *string) { +func SubGroupByFeed(pgs []*primaryGroup) ([]*models.PrimaryGroupDigestData, *string) { var allPrimaryGroups []*models.PrimaryGroupDigestData for _, pg := range pgs { @@ -46,9 +44,7 @@ func (g *FeedGrouper) GroupEntries(pgs []*primaryGroup) ([]*models.PrimaryGroupD }) } - for _, seg := range entryGroups { - seg.Title = fmt.Sprintf("%s - %s", pg.Title, seg.Title) - } + allPrimaryGroups = append(allPrimaryGroups, &models.PrimaryGroupDigestData{ ID: pg.ID, diff --git a/internal/digest/grouper.go b/internal/digest/grouper.go deleted file mode 100644 index c31defa..0000000 --- a/internal/digest/grouper.go +++ /dev/null @@ -1,26 +0,0 @@ -package digest - -import ( - "miniflux-digest/internal/app/services" - "miniflux-digest/internal/models" -) - -const ( - DayGroupLayout = "2006-01-02" - DayGroupTitleLayout = "Jan 2, 2006" -) - -type SubGrouper interface { - GroupEntries(pgs []*primaryGroup) ([]*models.PrimaryGroupDigestData, *string) -} - -func NewSubGrouper(subGroupBy string, llmService services.LLMService) SubGrouper { - switch subGroupBy { - case "ai": - return &LLMGrouper{LLMService: llmService} - case "feed": - return &FeedGrouper{} - default: // day - return &DayGrouper{} - } -} diff --git a/internal/digest/groups.go b/internal/digest/groups.go new file mode 100644 index 0000000..827e7ab --- /dev/null +++ b/internal/digest/groups.go @@ -0,0 +1,9 @@ +package digest + +import "miniflux-digest/internal/models" + +type primaryGroup struct { + ID int64 + Title string + Entries []*models.Entry +} diff --git a/internal/digest/llm_grouper.go b/internal/digest/llm_grouper.go index 8caa15e..32a23fa 100644 --- a/internal/digest/llm_grouper.go +++ b/internal/digest/llm_grouper.go @@ -28,9 +28,7 @@ type LLMResponse struct { } `json:"sub_groups"` } -type LLMGrouper struct { - LLMService services.LLMService -} + type llmEntry struct { ID int64 `json:"id"` @@ -120,7 +118,7 @@ var llmResponseSchema = &genai.Schema{ }, } -func (g *LLMGrouper) GroupEntries(pgs []*primaryGroup) ([]*models.PrimaryGroupDigestData, *string) { +func SubGroupByAI(pgs []*primaryGroup, llmService services.LLMService) ([]*models.PrimaryGroupDigestData, *string) { var llmEntries []llmEntry entryMap := make(map[int64]*models.Entry) primaryGroupMap := make(map[int64]*primaryGroup) @@ -153,7 +151,7 @@ func (g *LLMGrouper) GroupEntries(pgs []*primaryGroup) ([]*models.PrimaryGroupDi ctx, cancel := context.WithTimeout(context.Background(), LLMTimeout) defer cancel() - llmResponse, err := g.LLMService.GenerateContent(ctx, prompt, llmResponseSchema) + llmResponse, err := llmService.GenerateContent(ctx, prompt, llmResponseSchema) if err != nil { log.Printf("LLM service failed, falling back to feed grouping: %v\n", err) return fallbackToFeedGrouper(pgs) @@ -236,5 +234,5 @@ func (g *LLMGrouper) GroupEntries(pgs []*primaryGroup) ([]*models.PrimaryGroupDi } func fallbackToFeedGrouper(pgs []*primaryGroup) ([]*models.PrimaryGroupDigestData, *string) { - return (&FeedGrouper{}).GroupEntries(pgs) + return SubGroupByFeed(pgs) }