feat(podcasts): reverse order — position DESC, date-agnostic; single order helper
The 'Oldest first' order sorts by PUB DATE, which breaks on Scribl's migrated podiobooks feeds (messy/all-equal dates). New order value 'reverse' = feed position DESC with a date-DESC tiebreak — dates are not involved, so nothing can break. - services.OrderEpisodes: THE single order source of truth (§0 items 3+5a). The four surfaces (sequence JSON, reader chain context, SSR page list) previously carried three near-identical handwritten comparators — all now route through one generic helper. The SSR page list gains a SortDate key (lexical 2006-01-02) so the pub-date tiebreak is actually chronological; the old comparator compared 'Jan 2, 2006' display strings alphabetically (latent drift bug). - Dropdown becomes four options: Feed order / Feed order (reversed) / Oldest first (pub date) / Newest first (pub date). - Serial suggestion button renamed 'Reverse feed order' (sets reverse, not oldest) and its response out-of-band-swaps the order select (PodcastOrderSelectOOB — same mechanism as Download All's policy select), so the dropdown mirrors the server state immediately. - Validation: oneof=native reverse oldest newest everywhere. - Tests: reverse binds via the form wire; messy-dates sequence returns 3,2,1; existing reorder-safety stays green.
This commit is contained in:
1 parent
7c7c08748e
commit
e3675536aa
9 files changed
+708
-463
No files matched your search
@@ -9,7 +9,6 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
|
||||
@@ -39,8 +38,11 @@ type PodcastHandler struct {
|
||||
// EpisodeListRenderer renders the post-action episode list for an htmx
|
||||
// request. includePolicySelect additionally out-of-band-swaps the
|
||||
// auto-download select (Download All flips the caller's policy — the
|
||||
// dropdown must reflect it immediately, issue #3).
|
||||
type EpisodeListRenderer func(c *echo.Context, feedID string, includePolicySelect bool) error
|
||||
// dropdown must reflect it immediately, issue #3);
|
||||
// includeOrderSelect likewise rides along for the order endpoint (the
|
||||
// "Reverse feed order" button flips to reverse — the dropdown must
|
||||
// mirror the server state, §0 item 3).
|
||||
type EpisodeListRenderer func(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect bool) error
|
||||
|
||||
// SetEpisodeListRenderer wires the renderer (router-only; product wiring).
|
||||
func (h *PodcastHandler) SetEpisodeListRenderer(r EpisodeListRenderer) {
|
||||
@@ -49,9 +51,9 @@ func (h *PodcastHandler) SetEpisodeListRenderer(r EpisodeListRenderer) {
|
||||
|
||||
// hxEpisodeList answers an htmx action with the refreshed partial when
|
||||
// a renderer is wired; otherwise falls back to 204 + HX-Trigger.
|
||||
func (h *PodcastHandler) hxEpisodeList(c *echo.Context, feedID string, includePolicySelect bool) error {
|
||||
func (h *PodcastHandler) hxEpisodeList(c *echo.Context, feedID string, includePolicySelect, includeOrderSelect bool) error {
|
||||
if h.episodeListRenderer != nil {
|
||||
return h.episodeListRenderer(c, feedID, includePolicySelect)
|
||||
return h.episodeListRenderer(c, feedID, includePolicySelect, includeOrderSelect)
|
||||
}
|
||||
return hxAccepted(c, "")
|
||||
}
|
||||
@@ -328,7 +330,7 @@ func (h *PodcastHandler) UpdateSubscription(c *echo.Context) error {
|
||||
if isHXRequest(c) {
|
||||
// Policy changes feed the auto-download union on the next poll.
|
||||
_, _ = h.worker.ApplyAutoPolicies(ctx, feedID)
|
||||
return h.hxEpisodeList(c, feedID.String(), true)
|
||||
return h.hxEpisodeList(c, feedID.String(), true, false)
|
||||
}
|
||||
return c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
@@ -367,7 +369,7 @@ func (h *PodcastHandler) RefreshFeed(c *echo.Context) error {
|
||||
return podcastErrorResponse(c, err)
|
||||
}
|
||||
if isHXRequest(c) {
|
||||
return h.hxEpisodeList(c, feedID.String(), false)
|
||||
return h.hxEpisodeList(c, feedID.String(), false, false)
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "refreshed"})
|
||||
}
|
||||
@@ -418,7 +420,7 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error {
|
||||
// Download All flips the caller's policy to all (issue #3)
|
||||
// — the oob select keeps the dropdown honest.
|
||||
h.flipSubscriptionToAll(ctx, user.ID, feedID)
|
||||
return h.hxEpisodeList(c, feedID.String(), true)
|
||||
return h.hxEpisodeList(c, feedID.String(), true, false)
|
||||
}
|
||||
return c.JSON(http.StatusAccepted, map[string]interface{}{"queued": queued})
|
||||
}
|
||||
@@ -430,7 +432,7 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error {
|
||||
return podcastErrorResponse(c, err)
|
||||
}
|
||||
if isHXRequest(c) {
|
||||
return h.hxEpisodeList(c, feedID.String(), false)
|
||||
return h.hxEpisodeList(c, feedID.String(), false, false)
|
||||
}
|
||||
return c.JSON(http.StatusAccepted, map[string]string{"message": "queued"})
|
||||
}
|
||||
@@ -503,9 +505,10 @@ func podcastOrderSettingKey(feedID string) string {
|
||||
return "podcast_order:" + feedID
|
||||
}
|
||||
|
||||
// podcastOrderValid: native (feed position) | oldest | newest.
|
||||
// podcastOrderValid native (feed position) | reverse (position DESC,
|
||||
// no dates) | oldest | newest.
|
||||
func podcastOrderValid(o string) bool {
|
||||
return o == "native" || o == "oldest" || o == "newest"
|
||||
return services.ValidPodcastEpisodeOrder(o)
|
||||
}
|
||||
|
||||
// GetFeedOrder reads the caller's effective order for a feed
|
||||
@@ -538,7 +541,7 @@ func (h *PodcastHandler) SetFeedOrder(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
|
||||
}
|
||||
var req struct {
|
||||
Order string `json:"order" form:"order" validate:"required,oneof=native oldest newest"`
|
||||
Order string `json:"order" form:"order" validate:"required,oneof=native reverse oldest newest"`
|
||||
}
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
@@ -564,7 +567,7 @@ func (h *PodcastHandler) SetFeedOrder(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
if isHXRequest(c) {
|
||||
return h.hxEpisodeList(c, feedID.String(), false)
|
||||
return h.hxEpisodeList(c, feedID.String(), false, true)
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"order": req.Order})
|
||||
}
|
||||
@@ -590,19 +593,11 @@ func (h *PodcastHandler) GetFeedSequence(c *echo.Context) error {
|
||||
for i := range eps {
|
||||
views = append(views, episodeView(&eps[i]))
|
||||
}
|
||||
sortSlice(views, func(a, b PodcastEpisodeView) bool {
|
||||
switch order {
|
||||
case "oldest":
|
||||
return episodeSortKey(a) < episodeSortKey(b)
|
||||
case "newest":
|
||||
return episodeSortKey(a) > episodeSortKey(b)
|
||||
default: // native: feed position (fetch-maintained)
|
||||
if a.FeedPosition != b.FeedPosition {
|
||||
return a.FeedPosition < b.FeedPosition
|
||||
}
|
||||
return a.Date < b.Date
|
||||
}
|
||||
})
|
||||
// Effective listening order (order consolidation §0 item 5a).
|
||||
services.OrderEpisodes(views, order,
|
||||
func(v PodcastEpisodeView) int32 { return int32(v.FeedPosition) },
|
||||
func(v PodcastEpisodeView) string { return v.Date },
|
||||
)
|
||||
|
||||
sequence := make([]PodcastEpisodeView, 0, len(views))
|
||||
gaps := make([]PodcastEpisodeView, 0)
|
||||
@@ -621,12 +616,6 @@ func (h *PodcastHandler) GetFeedSequence(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// episodeSortKey: chronological key for oldest/newest (date, then
|
||||
// native position as the same-day tiebreaker).
|
||||
func episodeSortKey(v PodcastEpisodeView) string {
|
||||
return v.Date + fmt.Sprintf("%06d", v.FeedPosition)
|
||||
}
|
||||
|
||||
// sortSlice: sort.Slice with a controller-friendly wrapper.
|
||||
func sortSlice[T any](s []T, less func(a, b T) bool) {
|
||||
sort.Slice(s, func(i, j int) bool { return less(s[i], s[j]) })
|
||||
|
||||
Reference in new issue
Block a user