From 13789184ff1b9e2fd4f4c99d5241f1ff281c0346 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Thu, 30 Jul 2026 18:53:17 +0700 Subject: [PATCH] Render one chapter format, not "Ch Chapter 250" The userscript writes last_chapter/latest_chapter as "Chapter N" and the poller writes the same label, so the templates' own "Ch " prefix doubled it on every real row. A manual edit through the web UI stores a bare "250", so the list carried two formats at once and stopped being scannable. Move the formatting out of the templates into DisplayChapter/DisplayLatest on Bookmark, which strip a leading "Chapter"/"Ch." lead-in and re-add exactly one "Ch ", falling back to the numeric column when the label is empty. Co-Authored-By: Claude Opus 5 --- backend/store.go | 36 +++++++++++++++++++++++++++++++++++ backend/store_test.go | 38 +++++++++++++++++++++++++++++++++++++ backend/templates/app.html | 2 +- backend/templates/card.html | 6 +++--- 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/backend/store.go b/backend/store.go index 1684446..a911ef5 100644 --- a/backend/store.go +++ b/backend/store.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "regexp" + "strconv" "strings" _ "modernc.org/sqlite" @@ -41,6 +42,41 @@ func (b Bookmark) HasNewChapter() bool { return b.LatestChapterNum != nil && *b.LatestChapterNum > b.LastChapterNum } +// chapterLeadIn matches the prefix the userscript and the poller both write +// ("Chapter 250"), so the UI can add exactly one "Ch " of its own instead of +// doubling it. A manual edit through the web UI stores a bare "250", which is +// the same string minus the lead-in. +var chapterLeadIn = regexp.MustCompile(`(?i)^\s*(?:chapter|ch\.?)\s*`) + +func displayChapter(raw string, num float64) string { + rest := strings.TrimSpace(chapterLeadIn.ReplaceAllString(raw, "")) + if rest == "" { + rest = strconv.FormatFloat(num, 'f', -1, 64) + } + // "Ch " only makes sense in front of a number; anything else is a label the + // site gave us, so pass it through as written. + if rest[0] < '0' || rest[0] > '9' { + return rest + } + return "Ch " + rest +} + +// DisplayChapter is the read-progress line: one canonical "Ch N" whatever +// format the write came in as. +func (b Bookmark) DisplayChapter() string { + return displayChapter(b.LastChapter, b.LastChapterNum) +} + +// DisplayLatest is the same for the newest published chapter, which arrives +// with the same "Chapter N" lead-in from both the userscript and the poller. +func (b Bookmark) DisplayLatest() string { + var num float64 + if b.LatestChapterNum != nil { + num = *b.LatestChapterNum + } + return displayChapter(b.LatestChapter, num) +} + // ContinueURL is where the Continue button points: the chapter last read, or // the series page when no chapter URL was ever captured. func (b Bookmark) ContinueURL() string { diff --git a/backend/store_test.go b/backend/store_test.go index 3c1e231..03d4f1b 100644 --- a/backend/store_test.go +++ b/backend/store_test.go @@ -1034,3 +1034,41 @@ func TestOpenStoreMigratesAsuraHashlessCollision(t *testing.T) { t.Fatalf("merge kept wrong row: %+v", merged) } } + +func TestDisplayChapter(t *testing.T) { + cases := []struct { + name string + raw string + num float64 + want string + }{ + {"userscript label", "Chapter 250", 250, "Ch 250"}, + {"abbreviated with dot", "Ch. 12", 12, "Ch 12"}, + {"lowercase abbreviation", "ch 3", 3, "Ch 3"}, + {"bare number from a manual edit", "45.5", 45.5, "Ch 45.5"}, + {"empty falls back to the number", "", 7, "Ch 7"}, + {"empty and zero", "", 0, "Ch 0"}, + {"label with no number passes through", "Prologue", 0, "Prologue"}, + {"title-like label keeps its words", "Chapter of the Sun", 0, "of the Sun"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := displayChapter(tc.raw, tc.num); got != tc.want { + t.Errorf("displayChapter(%q, %v) = %q, want %q", tc.raw, tc.num, got, tc.want) + } + }) + } + + b := Bookmark{LastChapter: "Chapter 9", LastChapterNum: 9} + if got := b.DisplayChapter(); got != "Ch 9" { + t.Errorf("DisplayChapter() = %q, want %q", got, "Ch 9") + } + if got := b.DisplayLatest(); got != "Ch 0" { + t.Errorf("DisplayLatest() with nothing captured = %q, want %q", got, "Ch 0") + } + num := 11.0 + b.LatestChapter, b.LatestChapterNum = "Chapter 11", &num + if got := b.DisplayLatest(); got != "Ch 11" { + t.Errorf("DisplayLatest() = %q, want %q", got, "Ch 11") + } +} diff --git a/backend/templates/app.html b/backend/templates/app.html index e4e9b80..f133fc0 100644 --- a/backend/templates/app.html +++ b/backend/templates/app.html @@ -75,7 +75,7 @@ {{else if .Favorite}}{{end}} {{.Title}} - Ch {{.LastChapter}}{{if .HasNewChapter}} · New{{end}} + {{.DisplayChapter}}{{if .HasNewChapter}} · New{{end}} {{end}} diff --git a/backend/templates/card.html b/backend/templates/card.html index 5ab8dee..613ff62 100644 --- a/backend/templates/card.html +++ b/backend/templates/card.html @@ -21,10 +21,10 @@

{{.Site}} / - Ch {{.LastChapter}} + {{.DisplayChapter}} {{if and (eq .Status "reading") .HasNewChapter}} / - Ch {{.LatestChapter}} out + {{.DisplayLatest}} out {{end}} {{if eq .Status "archived"}} / @@ -86,7 +86,7 @@ hx-post="/ui/bookmarks/{{.Key}}/chapter" hx-target="[id='card-{{.Key}}']" hx-swap="outerHTML" hx-indicator="[id='card-{{.Key}}']" hx-disabled-elt="input, button"> - {{if .LatestChapter}}

Latest known: Ch {{.LatestChapter}}

{{end}} + {{if .LatestChapter}}

Latest known: {{.DisplayLatest}}

{{end}}
{{/* max is a fat-finger guard, not a real ceiling — no series is near it. */}}