No ranking data. Use the page buttons above to fetch from novelfire.net.
{{end}}
`
// rankingViewItem enriches a RankingItem with whether it is present in the
// local book library, so the template can highlight it differently.
type rankingViewItem struct {
writer.RankingItem
Local bool
}
// toRankingViewItems annotates items with Local=true for slugs found in localSlugs.
func toRankingViewItems(items []writer.RankingItem, localSlugs map[string]bool) []rankingViewItem {
out := make([]rankingViewItem, len(items))
for i, it := range items {
out[i] = rankingViewItem{
RankingItem: it,
Local: localSlugs[it.Slug],
}
}
return out
}
// pageNum is one entry in the ranking pagination bar.
// Num == 0 is a sentinel that renders as an ellipsis gap.
type pageNum struct {
Num int
}
// rankingPageNums builds the dynamic pagination list for 100 pages with
// smart ellipsis: always show the first 3, last 3, and a sliding window of
// 3 around the ends — compressed with "…" separators between runs.
//
// For a flat 100-page list it produces:
//
// 1 2 3 … 6 7 8 … 93 94 95 … 98 99 100
//
// (The middle range is omitted intentionally; users click individual pages.)
func rankingPageNums(total int) []pageNum {
if total <= 0 {
return nil
}
// Build the set of visible page numbers using a sliding-window approach.
show := make(map[int]bool)
// Always show first 3 and last 3.
for i := 1; i <= 3 && i <= total; i++ {
show[i] = true
}
for i := total - 2; i <= total; i++ {
if i >= 1 {
show[i] = true
}
}
// For large ranges, show a few pages near the 1/4 and 3/4 marks.
if total > 12 {
q1 := total / 4
q3 := total * 3 / 4
for _, p := range []int{q1 - 1, q1, q1 + 1, q3 - 1, q3, q3 + 1} {
if p >= 1 && p <= total {
show[p] = true
}
}
}
// Collect sorted visible pages.
pages := make([]int, 0, len(show))
for p := range show {
pages = append(pages, p)
}
// Sort pages slice.
for i := 0; i < len(pages); i++ {
for j := i + 1; j < len(pages); j++ {
if pages[j] < pages[i] {
pages[i], pages[j] = pages[j], pages[i]
}
}
}
// Build output with ellipsis sentinels (Num==0) between non-consecutive pages.
out := make([]pageNum, 0, len(pages)*2)
for i, p := range pages {
if i > 0 && p > pages[i-1]+1 {
out = append(out, pageNum{0}) // ellipsis
}
out = append(out, pageNum{p})
}
return out
}
// handleRanking serves the ranking page from the cached ranking.md file.
// It does NOT trigger a live scrape; use POST /ranking/refresh for that.
func (s *Server) handleRanking(w http.ResponseWriter, r *http.Request) {
rankingItems, err := s.writer.ReadRankingItems()
if err != nil {
s.log.Error("failed to read cached ranking", "err", err)
}
cachedAt := ""
if info, statErr := s.writer.RankingFileInfo(); statErr == nil {
cachedAt = info.ModTime().Format("Jan 2, 2006 at 15:04")
}
t := template.Must(template.New("ranking").Parse(rankingTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Books interface{}
CachedAt string
PageNums []pageNum
}{
Books: toRankingViewItems(rankingItems, s.writer.LocalSlugs()),
CachedAt: cachedAt,
PageNums: rankingPageNums(100),
})
s.respond(w, r, "Rankings", buf.String())
}
// handleRankingRefresh starts an async scrape of novelfire.net/ranking and
// immediately returns a polling badge. The browser polls /ui/ranking/status
// until the job finishes, then follows an HX-Redirect back to /ranking.
//
// Accepts an optional form field "pages" (integer ≥ 1). 0 or absent means
// fetch all pages; otherwise at most that many pages are scraped.
func (s *Server) handleRankingRefresh(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
maxPages := 0
if p := strings.TrimSpace(r.FormValue("pages")); p != "" {
if n, err := strconv.Atoi(p); err == nil && n > 0 {
maxPages = n
}
}
s.mu.Lock()
if s.rankingRunning {
s.mu.Unlock()
renderFragment(w, rankingStatusHTML("running", "Ranking refresh already in progress…"))
return
}
s.rankingRunning = true
s.mu.Unlock()
go func() {
defer func() {
s.mu.Lock()
s.rankingRunning = false
s.mu.Unlock()
}()
// Allow ~90 s per page; minimum 120 s for a single page.
timeout := 120 * time.Second
if maxPages > 1 {
timeout = time.Duration(maxPages) * 90 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
rankingCh, errCh := s.novel.ScrapeRanking(ctx, maxPages)
var rankingItems []writer.RankingItem
for rankingCh != nil || errCh != nil {
select {
case meta, ok := <-rankingCh:
if !ok {
rankingCh = nil
} else {
rankingItems = append(rankingItems, writer.RankingItem{
Rank: meta.Ranking,
Slug: meta.Slug,
Title: meta.Title,
Author: meta.Author,
Cover: meta.Cover,
Status: meta.Status,
Genres: meta.Genres,
SourceURL: meta.SourceURL,
})
}
case err, ok := <-errCh:
if !ok {
errCh = nil
} else if err != nil {
s.log.Error("ranking scrape error", "err", err)
}
}
}
if len(rankingItems) > 0 {
if err := s.writer.WriteRanking(rankingItems); err != nil {
s.log.Error("failed to save ranking", "err", err)
}
}
}()
renderFragment(w, rankingStatusHTML("running", "Fetching rankings…"))
}
// handleRankingStatus is the HTMX polling endpoint for ranking refresh jobs.
// While running it returns a self-replacing badge; when done it issues an
// HX-Redirect so the browser navigates to /ranking.
func (s *Server) handleRankingStatus(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
running := s.rankingRunning
s.mu.Unlock()
if running {
renderFragment(w, rankingStatusHTML("running", "Fetching rankings…"))
return
}
// Job done — redirect the HTMX request to the ranking page.
w.Header().Set("HX-Redirect", "/ranking")
w.WriteHeader(http.StatusOK)
}
// rankingStatusHTML returns a self-replacing polling badge for the ranking
// refresh job. state is "running" or "done".
func rankingStatusHTML(state, msg string) string {
var colour, dot, poll string
switch state {
case "running":
colour = "text-amber-300 bg-amber-950 border-amber-800"
dot = ``
poll = `hx-get="/ui/ranking/status" hx-trigger="every 3s" hx-target="this" hx-swap="outerHTML"`
default:
colour = "text-green-300 bg-green-950 border-green-800"
dot = ``
}
return fmt.Sprintf(
`