From e04e9f0271c12593864431691a77e5ebef22a535 Mon Sep 17 00:00:00 2001 From: EliasLd Date: Wed, 23 Sep 2026 15:31:53 +0200 Subject: [PATCH 01/11] feat(chapter_number): dedicated parsing utility --- internal/source/common/chapter_number.go | 95 +++++++++++++++++++ internal/source/common/chapter_number_test.go | 32 +++++++ internal/source/types/types.go | 2 +- 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 internal/source/common/chapter_number.go create mode 100644 internal/source/common/chapter_number_test.go diff --git a/internal/source/common/chapter_number.go b/internal/source/common/chapter_number.go new file mode 100644 index 0000000..2b73e87 --- /dev/null +++ b/internal/source/common/chapter_number.go @@ -0,0 +1,95 @@ +package common + +import ( + "fmt" + "strings" +) + +// ChapterNumber stores a canonical, non-negative decimal number exactly. +// Examples: "0", "6.5", "14.25", "100". Never convert it to float64. +type ChapterNumber string + +func ParseChapterNumber(raw string) (ChapterNumber, error) { + text := strings.TrimSpace(raw) + parts := strings.Split(text, ".") + if len(parts) < 1 || len(parts) > 2 || !decimalDigits(parts[0]) || + (len(parts) == 2 && !decimalDigits(parts[1])) { + return "", fmt.Errorf("invalid chapter number %q", raw) + } + + whole := strings.TrimLeft(parts[0], "0") + if whole == "" { + whole = "0" + } + if len(parts) == 1 { + return ChapterNumber(whole), nil + } + + fraction := strings.TrimRight(parts[1], "0") + if fraction == "" { + return ChapterNumber(whole), nil + } + return ChapterNumber(whole + "." + fraction), nil +} + +func decimalDigits(text string) bool { + if text == "" { + return false + } + for i := 0; i < len(text); i++ { + if text[i] < '0' || text[i] > '9' { + return false + } + } + return true +} + +func (n ChapterNumber) String() string { return string(n) } + +func (n ChapterNumber) IsInteger() bool { + return !strings.Contains(string(n), ".") +} + +func (n ChapterNumber) IntegerDigits() int { + parts := strings.SplitN(string(n), ".", 2) + return len(parts[0]) +} + +func (n ChapterNumber) Padded(width int) string { + parts := strings.SplitN(string(n), ".", 2) + whole := parts[0] + if len(whole) < width { + whole = strings.Repeat("0", width-len(whole)) + whole + } + if len(parts) == 2 { + return whole + "." + parts[1] + } + return whole +} + +// Returns -1, 0 or 1. Both operands must come from ParseChapterNumber. +func (n ChapterNumber) Compare(other ChapterNumber) int { + left := strings.SplitN(string(n), ".", 2) + right := strings.SplitN(string(other), ".", 2) + if len(left[0]) < len(right[0]) { + return -1 + } + if len(left[0]) > len(right[0]) { + return 1 + } + if c := strings.Compare(left[0], right[0]); c != 0 { + return c + } + + a, b := "", "" + if len(left) == 2 { + a = left[1] + } + if len(right) == 2 { + b = right[1] + } + size := max(len(a), len(b)) + a += strings.Repeat("0", size-len(a)) + b += strings.Repeat("0", size-len(b)) + return strings.Compare(a, b) +} diff --git a/internal/source/common/chapter_number_test.go b/internal/source/common/chapter_number_test.go new file mode 100644 index 0000000..edb0697 --- /dev/null +++ b/internal/source/common/chapter_number_test.go @@ -0,0 +1,32 @@ +package common + +import "testing" + +func TestParseChapterNumber(t *testing.T) { + valid := map[string]string{"0": "0", "00.000": "0", "06.500": "6.5", "14.2500": "14.25", "0010": "10", "1.01": "1.01"} + for input, want := range valid { + number, err := ParseChapterNumber(input) + if err != nil || number.String() != want { + t.Errorf("%q: got %q err %v want %q", input, number, err, want) + } + } + for _, input := range []string{"", "-1", ".5", "1.", "1..2", "1.2a", "+1"} { + if _, err := ParseChapterNumber(input); err == nil { + t.Errorf("expected %q to be invalid", input) + } + } +} +func TestNumberCompareAndPadding(t *testing.T) { + ordered := []string{"0", "0.5", "1", "1.01", "1.1", "6.5", "7", "9.9", "10", "14.25"} + for i := 0; i < len(ordered)-1; i++ { + a, _ := ParseChapterNumber(ordered[i]) + b, _ := ParseChapterNumber(ordered[i+1]) + if a.Compare(b) >= 0 { + t.Errorf("expected %s < %s", a, b) + } + } + a, _ := ParseChapterNumber("6.5") + if a.Padded(3) != "006.5" { + t.Fatalf("got %q", a.Padded(3)) + } +} diff --git a/internal/source/types/types.go b/internal/source/types/types.go index ed5c652..946cf70 100644 --- a/internal/source/types/types.go +++ b/internal/source/types/types.go @@ -18,7 +18,7 @@ type SearchResult struct { } type Entry struct { - Number int + Number common.ChapterNumber Label string URL string } From 5e509a1363db0139f96b5bf5bb66d9a2c0fb5d39 Mon Sep 17 00:00:00 2001 From: EliasLd Date: Wed, 23 Sep 2026 15:33:56 +0200 Subject: [PATCH 02/11] fix(range_parse): use common.ChapterNumber instead of int --- internal/app/range_parse.go | 138 +++++++++++++----------------------- 1 file changed, 50 insertions(+), 88 deletions(-) diff --git a/internal/app/range_parse.go b/internal/app/range_parse.go index d4d0d99..7e82694 100644 --- a/internal/app/range_parse.go +++ b/internal/app/range_parse.go @@ -2,12 +2,17 @@ package app import ( "fmt" + "strconv" "strings" + + "github.com/EliasLd/goweeb/internal/source/common" ) type RangeSpec struct { - Range [2]int + Start common.ChapterNumber + End common.ChapterNumber Mode RangeMode + Count int // Only for RangeLastN; never used as a chapter number. } type RangeSelection struct { @@ -15,114 +20,71 @@ type RangeSelection struct { Ranges []RangeSpec } -// Parses: -// n, n-m, n-, -n -func ParseRangeString(rangeStr string) ([2]int, RangeMode, error) { - var chapterRange [2]int - var rangeMode RangeMode = RangeNone - - rangeStr = strings.TrimSpace(rangeStr) - if rangeStr == "" { - return chapterRange, rangeMode, fmt.Errorf("invalid range format: empty input") +// Parses: 0, 6.5, 1-10.5, 6.5-, -10. +func ParseRangeString(input string) (RangeSpec, error) { + raw := strings.TrimSpace(input) + invalid := func() (RangeSpec, error) { + return RangeSpec{}, fmt.Errorf("invalid range %q; use 0, 6.5, 1-10.5, 6.5- or -10", input) + } + if raw == "" { + return invalid() } - // Handle last N chapters: -N - if strings.HasPrefix(rangeStr, "-") && len(rangeStr) > 1 { - var nLast int - n, err := fmt.Sscanf(rangeStr, "-%d", &nLast) - if err != nil || n != 1 || nLast <= 0 { - return chapterRange, rangeMode, fmt.Errorf("invalid range format: %s. Use 1-10, 10, -10, 10- or all", rangeStr) + if strings.HasPrefix(raw, "-") { + count, err := strconv.Atoi(strings.TrimPrefix(raw, "-")) + if err != nil || count <= 0 { + return invalid() } - chapterRange[0] = 0 - chapterRange[1] = nLast - rangeMode = RangeLastN - return chapterRange, rangeMode, nil + return RangeSpec{Mode: RangeLastN, Count: count}, nil } - - // Handle open-ended: N- - if strings.HasSuffix(rangeStr, "-") { - var start int - trimmed := strings.TrimSuffix(rangeStr, "-") - n, err := fmt.Sscanf(trimmed, "%d", &start) - if err != nil || n != 1 || start <= 0 { - return chapterRange, rangeMode, fmt.Errorf("invalid range format: %s. Use 1-10, 10, -10, 10- or all", rangeStr) + if strings.HasSuffix(raw, "-") { + start, err := common.ParseChapterNumber(strings.TrimSpace(strings.TrimSuffix(raw, "-"))) + if err != nil { + return invalid() } - chapterRange[0] = start - chapterRange[1] = 0 - rangeMode = RangeOpenEnded - return chapterRange, rangeMode, nil + return RangeSpec{Mode: RangeOpenEnded, Start: start}, nil } - - // Handle normal range: N-M - var start, end int - n, err := fmt.Sscanf(rangeStr, "%d-%d", &start, &end) - if err == nil && n == 2 && start > 0 && end > 0 && start <= end { - chapterRange[0] = start - chapterRange[1] = end - rangeMode = RangeNormal - return chapterRange, rangeMode, nil + if strings.Contains(raw, "-") { + parts := strings.Split(raw, "-") + if len(parts) != 2 { + return invalid() + } + start, err := common.ParseChapterNumber(strings.TrimSpace(parts[0])) + if err != nil { + return invalid() + } + end, err := common.ParseChapterNumber(strings.TrimSpace(parts[1])) + if err != nil || start.Compare(end) > 0 { + return invalid() + } + return RangeSpec{Mode: RangeNormal, Start: start, End: end}, nil } - - // Handle single chapter: N - var solo int - n, err = fmt.Sscanf(rangeStr, "%d", &solo) - if err == nil && n == 1 && solo > 0 { - chapterRange[0] = solo - chapterRange[1] = solo - rangeMode = RangeNormal - return chapterRange, rangeMode, nil + number, err := common.ParseChapterNumber(raw) + if err != nil { + return invalid() } - - return chapterRange, rangeMode, fmt.Errorf("invalid range format: %s. Use 1-10, 10, -10, 10- or all", rangeStr) + return RangeSpec{Mode: RangeNormal, Start: number, End: number}, nil } func ParseRangeExpression(input string) (RangeSelection, error) { - var selection RangeSelection - input = strings.TrimSpace(input) if input == "" { - return selection, fmt.Errorf("invalid range format: empty input") + return RangeSelection{}, fmt.Errorf("invalid range expression: empty input") } - if strings.EqualFold(input, "all") { - selection.All = true - return selection, nil + return RangeSelection{All: true}, nil } - parts := strings.Split(input, ",") - + selection := RangeSelection{Ranges: make([]RangeSpec, 0, len(parts))} for _, part := range parts { - part = strings.TrimSpace(part) - - if part == "" { - return selection, fmt.Errorf( - "invalid range expression: %s", - input, - ) - } - - chapterRange, mode, err := ParseRangeString(part) + spec, err := ParseRangeString(strings.TrimSpace(part)) if err != nil { - return selection, err + return RangeSelection{}, err } - - // "-10" means "last 10 available chapters". - // Mixing that with explicit numerical ranges would make - // the semantics unnecessarily confusing. - if mode == RangeLastN && len(parts) > 1 { - return selection, fmt.Errorf( - "last-N syntax cannot be combined with other ranges", - ) + if spec.Mode == RangeLastN && len(parts) > 1 { + return RangeSelection{}, fmt.Errorf("last-N syntax cannot be combined with other ranges") } - - selection.Ranges = append( - selection.Ranges, - RangeSpec{ - Range: chapterRange, - Mode: mode, - }, - ) + selection.Ranges = append(selection.Ranges, spec) } - return selection, nil } From 4c006e51295a9254880c1af5ffc5ff2ed939e2da Mon Sep 17 00:00:00 2001 From: EliasLd Date: Wed, 23 Sep 2026 15:36:45 +0200 Subject: [PATCH 03/11] feat(range_available): handle decimal chapters --- internal/app/range_available.go | 96 +++++++++++---------------------- 1 file changed, 31 insertions(+), 65 deletions(-) diff --git a/internal/app/range_available.go b/internal/app/range_available.go index fc89c9f..7219c20 100644 --- a/internal/app/range_available.go +++ b/internal/app/range_available.go @@ -1,98 +1,64 @@ package app import ( - "fmt" + "math/big" "sort" "strings" + "github.com/EliasLd/goweeb/internal/source/common" sourcetypes "github.com/EliasLd/goweeb/internal/source/types" ) -type AvailableRange struct { - Start int - End int +type AvailableRange struct{ Start, End common.ChapterNumber } + +func nextInteger(previous, next common.ChapterNumber) bool { + if !previous.IsInteger() || !next.IsInteger() { + return false + } + value, ok := new(big.Int).SetString(previous.String(), 10) + if !ok { + return false + } + value.Add(value, big.NewInt(1)) + return value.String() == next.String() } -func AvailableChapterRanges( - entries []sourcetypes.Entry, -) []AvailableRange { +func AvailableChapterRanges(entries []sourcetypes.Entry) []AvailableRange { if len(entries) == 0 { return nil } - - numbers := make([]int, 0, len(entries)) - seen := make(map[int]struct{}) - - for _, entry := range entries { - if _, exists := seen[entry.Number]; exists { + seen := make(map[string]struct{}, len(entries)) + numbers := make([]common.ChapterNumber, 0, len(entries)) + for _, e := range entries { + if _, ok := seen[e.Number.String()]; ok { continue } - - seen[entry.Number] = struct{}{} - numbers = append(numbers, entry.Number) + seen[e.Number.String()] = struct{}{} + numbers = append(numbers, e.Number) } - - sort.Ints(numbers) - - ranges := make([]AvailableRange, 0) - - start := numbers[0] - previous := numbers[0] - + sort.Slice(numbers, func(i, j int) bool { return numbers[i].Compare(numbers[j]) < 0 }) + ranges := make([]AvailableRange, 0, len(numbers)) + start, previous := numbers[0], numbers[0] for _, number := range numbers[1:] { - if number == previous+1 { + if nextInteger(previous, number) { previous = number continue } - - ranges = append( - ranges, - AvailableRange{ - Start: start, - End: previous, - }, - ) - - start = number - previous = number + ranges = append(ranges, AvailableRange{Start: start, End: previous}) + start, previous = number, number } - - ranges = append( - ranges, - AvailableRange{ - Start: start, - End: previous, - }, - ) - - return ranges + return append(ranges, AvailableRange{Start: start, End: previous}) } -// Joints ranges of available chapters/volumes in a single string -// according to the format: -// n_1-m_1,n_2-m_2,...,n_n-m_n -func FormatAvailableRanges( - entries []sourcetypes.Entry, -) string { +func FormatAvailableRanges(entries []sourcetypes.Entry) string { ranges := AvailableChapterRanges(entries) - parts := make([]string, 0, len(ranges)) - for _, r := range ranges { if r.Start == r.End { - parts = append( - parts, - fmt.Sprintf("%d", r.Start), - ) - + parts = append(parts, r.Start.String()) continue } - - parts = append( - parts, - fmt.Sprintf("%d-%d", r.Start, r.End), - ) + parts = append(parts, r.Start.String()+"-"+r.End.String()) } - return strings.Join(parts, ", ") } From f8885f92dc7a05df6eb416d5190f89c2c4378ed9 Mon Sep 17 00:00:00 2001 From: EliasLd Date: Wed, 23 Sep 2026 15:39:05 +0200 Subject: [PATCH 04/11] feat: handle decimal chapters in range filtering --- internal/app/range_filter.go | 82 ++++++++++++++---------------------- internal/app/range_test.go | 71 +++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 50 deletions(-) create mode 100644 internal/app/range_test.go diff --git a/internal/app/range_filter.go b/internal/app/range_filter.go index 3cba38e..c47a667 100644 --- a/internal/app/range_filter.go +++ b/internal/app/range_filter.go @@ -1,71 +1,53 @@ package app import ( + "sort" + sourcetypes "github.com/EliasLd/goweeb/internal/source/types" ) -func FilterEntriesBySelection( - entries []sourcetypes.Entry, - selection RangeSelection, -) []sourcetypes.Entry { - if selection.All { - return entries +func FilterEntriesBySelection(entries []sourcetypes.Entry, selection RangeSelection) []sourcetypes.Entry { + if len(entries) == 0 { + return nil + } + + ordered := append([]sourcetypes.Entry(nil), entries...) + sort.SliceStable(ordered, func(i, j int) bool { return ordered[i].Number.Compare(ordered[j].Number) < 0 }) + unique := make([]sourcetypes.Entry, 0, len(ordered)) + seen := make(map[string]struct{}, len(ordered)) + for _, e := range ordered { + key := e.Number.String() + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + unique = append(unique, e) } + if selection.All { + return unique + } if len(selection.Ranges) == 0 { return nil } - - // Last N is intentionally only allowed as a standalone selector. if selection.Ranges[0].Mode == RangeLastN { - n := selection.Ranges[0].Range[1] - - if n > len(entries) { - n = len(entries) + count := selection.Ranges[0].Count + if count > len(unique) { + count = len(unique) } - - return entries[len(entries)-n:] + return unique[len(unique)-count:] } - selected := make( - []sourcetypes.Entry, - 0, - len(entries), - ) - - seen := make(map[int]struct{}) - - for _, entry := range entries { - for _, requested := range selection.Ranges { - start := requested.Range[0] - end := requested.Range[1] - - matches := false - - switch requested.Mode { - case RangeOpenEnded: - matches = entry.Number >= start - - case RangeNormal: - matches = - entry.Number >= start && - entry.Number <= end - } - - if !matches { - continue - } - - if _, exists := seen[entry.Number]; exists { + selected := make([]sourcetypes.Entry, 0, len(unique)) + for _, e := range unique { + for _, r := range selection.Ranges { + matches := r.Mode == RangeOpenEnded && e.Number.Compare(r.Start) >= 0 || + r.Mode == RangeNormal && e.Number.Compare(r.Start) >= 0 && e.Number.Compare(r.End) <= 0 + if matches { + selected = append(selected, e) break } - - seen[entry.Number] = struct{}{} - selected = append(selected, entry) - - break } } - return selected } diff --git a/internal/app/range_test.go b/internal/app/range_test.go new file mode 100644 index 0000000..0322bb3 --- /dev/null +++ b/internal/app/range_test.go @@ -0,0 +1,71 @@ +package app + +import ( + "github.com/EliasLd/goweeb/internal/source/common" + sourcetypes "github.com/EliasLd/goweeb/internal/source/types" + "testing" +) + +func entries(numbers ...string) []sourcetypes.Entry { + out := make([]sourcetypes.Entry, 0, len(numbers)) + for _, s := range numbers { + n, err := common.ParseChapterNumber(s) + if err != nil { + panic(err) + } + out = append(out, sourcetypes.Entry{Number: n}) + } + return out +} +func labels(list []sourcetypes.Entry) []string { + out := make([]string, 0, len(list)) + for _, e := range list { + out = append(out, e.Number.String()) + } + return out +} +func TestAvailableRangesWithDecimalBreaks(t *testing.T) { + input := entries("6.5", "3", "2", "1", "5", "4", "6", "7", "8", "8", "0") + got := FormatAvailableRanges(input) + if got != "0-6, 6.5, 7-8" { + t.Fatalf("got %q", got) + } +} +func TestParseAndFilter(t *testing.T) { + input := entries("8", "7", "6.5", "6", "5", "0.5", "0", "0", "1") + tests := []struct { + expression string + want string + }{ + {"0", "0"}, {"0-", "0,0.5,1,5,6,6.5,7,8"}, + {"6.5", "6.5"}, {"6-7", "6,6.5,7"}, + {"0-1,6.5", "0,0.5,1,6.5"}, {"-3", "6.5,7,8"}, + {"all", "0,0.5,1,5,6,6.5,7,8"}, + } + for _, tc := range tests { + selection, err := ParseRangeExpression(tc.expression) + if err != nil { + t.Errorf("%s: %v", tc.expression, err) + continue + } + got := stringsJoin(labels(FilterEntriesBySelection(input, selection))) + if got != tc.want { + t.Errorf("%s got %q want %q", tc.expression, got, tc.want) + } + } + for _, invalid := range []string{"-0", "-1.5", "1.2.3", "2-1", "1-2-3", "-2,1-3", "1,", "1-abc"} { + if _, err := ParseRangeExpression(invalid); err == nil { + t.Errorf("expected invalid: %q", invalid) + } + } +} +func stringsJoin(items []string) string { + if len(items) == 0 { + return "" + } + s := items[0] + for _, item := range items[1:] { + s += "," + item + } + return s +} From c404048fd2e42c972921691344127d73bc55c351 Mon Sep 17 00:00:00 2001 From: EliasLd Date: Wed, 23 Sep 2026 15:44:30 +0200 Subject: [PATCH 05/11] feat(mangadex): handle decimal chapters --- internal/source/mangadex/scraper/chapters.go | 24 ++++++++------------ 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/internal/source/mangadex/scraper/chapters.go b/internal/source/mangadex/scraper/chapters.go index 42180fe..b3b9c13 100644 --- a/internal/source/mangadex/scraper/chapters.go +++ b/internal/source/mangadex/scraper/chapters.go @@ -11,6 +11,7 @@ import ( "time" "github.com/EliasLd/goweeb/internal/logger" + "github.com/EliasLd/goweeb/internal/source/common" ) const chapterFeedPageSize = 100 @@ -21,7 +22,7 @@ type ScanInfo struct { } type ChapterInfo struct { - Number int + Number common.ChapterNumber Label string URL string ID string @@ -130,7 +131,7 @@ func GetScanInfo( // // goweeb currently expects one Entry per chapter number, so keep // the most recently published version. - chaptersByNumber := make(map[int]ChapterInfo) + chaptersByNumber := make(map[common.ChapterNumber]ChapterInfo) offset := 0 skippedNonInteger := 0 @@ -274,17 +275,14 @@ func GetScanInfo( continue } - chapterNumber, err := strconv.Atoi( + chapterNumber, err := common.ParseChapterNumber( chapterNumberText, ) if err != nil { - skippedNonInteger++ - log.Debug( - "Skipping unsupported non-integer MangaDex chapter: %s\n", + "Skipping invalid MangaDex chapter number: %q\n", chapterNumberText, ) - continue } @@ -360,13 +358,11 @@ func GetScanInfo( ) } - sort.Slice( - chapters, - func(i, j int) bool { - return chapters[i].Number < - chapters[j].Number - }, - ) + sort.Slice(chapters, func(i, j int) bool { + return chapters[i].Number.Compare( + chapters[j].Number, + ) < 0 + }) if len(chapters) == 0 { return nil, fmt.Errorf( From 524c7595c12a1a29ffbf29a3687775b916f6541c Mon Sep 17 00:00:00 2001 From: EliasLd Date: Wed, 23 Sep 2026 15:45:05 +0200 Subject: [PATCH 06/11] feat(mangavyvy): handle decimal chapters --- internal/source/mangavyvy/scraper/chapters.go | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/internal/source/mangavyvy/scraper/chapters.go b/internal/source/mangavyvy/scraper/chapters.go index 47ed79e..08d41be 100644 --- a/internal/source/mangavyvy/scraper/chapters.go +++ b/internal/source/mangavyvy/scraper/chapters.go @@ -5,17 +5,17 @@ import ( "net/http" "net/url" "sort" - "strconv" "strings" "time" "github.com/EliasLd/goweeb/internal/logger" + "github.com/EliasLd/goweeb/internal/source/common" "github.com/PuerkitoBio/goquery" ) type Chapter struct { - Number int + Number common.ChapterNumber Label string URL string } @@ -108,13 +108,12 @@ func ListChapters( return } - number, err := strconv.Atoi(numberStr) + number, err := common.ParseChapterNumber(numberStr) if err != nil { log.Debug( - "Skipping unsupported non-integer Mangavyvy chapter: %s\n", + "Skipping invalid Mangavyvy chapter number: %q\n", numberStr, ) - return } @@ -145,10 +144,7 @@ func ListChapters( ) if label == "" { - label = fmt.Sprintf( - "Chapter %d", - number, - ) + label = "Chapter " + number.String() } chapters = append( @@ -170,13 +166,11 @@ func ListChapters( // Mangavyvy renders newest chapters first. // goweeb expects entries in ascending chapter order. - sort.Slice( - chapters, - func(i, j int) bool { - return chapters[i].Number < - chapters[j].Number - }, - ) + sort.Slice(chapters, func(i, j int) bool { + return chapters[i].Number.Compare( + chapters[j].Number, + ) < 0 + }) log.Debug( "Found %d Mangavyvy chapter(s)\n", From 2f3ecfec8d206a4a934674f90348397c80a8d7b4 Mon Sep 17 00:00:00 2001 From: EliasLd Date: Wed, 23 Sep 2026 15:45:57 +0200 Subject: [PATCH 07/11] feat(weebcentral): fetch and handle decimal chapters --- .../source/weebcentral/scraper/chapters.go | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/internal/source/weebcentral/scraper/chapters.go b/internal/source/weebcentral/scraper/chapters.go index 2909c0a..5666a58 100644 --- a/internal/source/weebcentral/scraper/chapters.go +++ b/internal/source/weebcentral/scraper/chapters.go @@ -6,11 +6,11 @@ import ( "net/url" "regexp" "sort" - "strconv" "strings" "time" "github.com/EliasLd/goweeb/internal/logger" + "github.com/EliasLd/goweeb/internal/source/common" "github.com/PuerkitoBio/goquery" ) @@ -20,26 +20,30 @@ type ScanInfo struct { } type ChapterInfo struct { - Number int + Number common.ChapterNumber Label string URL string } var chapterLabelPattern = regexp.MustCompile( - `(?i)^(?:chapter\s*|#\s*)(\d+)`, + `(?i)^(?:chapter\s*|#\s*)(\d+(?:\.\d+)?)(?:$|[\s:-])`, ) -func parseChapterLabel(text string) (int, string, bool) { +func parseChapterLabel( + text string, +) (common.ChapterNumber, string, bool) { + var zero common.ChapterNumber + label := strings.TrimSpace(text) match := chapterLabelPattern.FindStringSubmatch(label) if len(match) != 2 { - return 0, "", false + return zero, "", false } - number, err := strconv.Atoi(match[1]) + number, err := common.ParseChapterNumber(match[1]) if err != nil { - return 0, "", false + return zero, "", false } return number, label, true @@ -142,7 +146,7 @@ func GetScanInfo( } var ( - chapterNumber int + chapterNumber common.ChapterNumber label string found bool ) @@ -214,7 +218,9 @@ func GetScanInfo( // WeebCentral returns newest chapters first. // goweeb expects chapters in ascending order. sort.Slice(chapters, func(i, j int) bool { - return chapters[i].Number < chapters[j].Number + return chapters[i].Number.Compare( + chapters[j].Number, + ) < 0 }) return &ScanInfo{ From b0d62f3acb070a8b9e894348df3607c9fefb2a8a Mon Sep 17 00:00:00 2001 From: EliasLd Date: Wed, 23 Sep 2026 15:46:33 +0200 Subject: [PATCH 08/11] fix(animesama): handle decimal chapters when available --- internal/source/animesama/provider.go | 11 +++++-- .../source/animesama/scraper/scan_info.go | 32 +++++++++++++++---- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/internal/source/animesama/provider.go b/internal/source/animesama/provider.go index fb2e8db..72f31df 100644 --- a/internal/source/animesama/provider.go +++ b/internal/source/animesama/provider.go @@ -93,9 +93,14 @@ func (p *Provider) ListEntries(workURL string, scanPath string, log *logger.Logg entries := make([]sourcetypes.Entry, 0, len(scanInfo.Chapters)) for _, ch := range scanInfo.Chapters { entries = append(entries, sourcetypes.Entry{ - Number: ch, - Label: fmt.Sprintf("Chapter %d", ch), - URL: fmt.Sprintf("%s/s2/scans/%s/%d", p.domain, scanInfo.MangaName, ch), + Number: ch.Number, + Label: "Chapter " + ch.Number.String(), + URL: fmt.Sprintf( + "%s/s2/scans/%s/%s", + p.domain, + scanInfo.MangaName, + url.PathEscape(ch.Raw), + ), }) } diff --git a/internal/source/animesama/scraper/scan_info.go b/internal/source/animesama/scraper/scan_info.go index d4fed09..b7ea20b 100644 --- a/internal/source/animesama/scraper/scan_info.go +++ b/internal/source/animesama/scraper/scan_info.go @@ -7,15 +7,20 @@ import ( "net/http" "net/url" "sort" - "strconv" "time" "github.com/EliasLd/goweeb/internal/logger" + "github.com/EliasLd/goweeb/internal/source/common" ) +type ChapterInfo struct { + Number common.ChapterNumber + Raw string +} + type ScanInfo struct { MangaName string - Chapters []int + Chapters []ChapterInfo } // Fetches scan info using the anime-sama API @@ -60,16 +65,29 @@ func GetScanInfo(domain, mangaName string, log *logger.Logger) (*ScanInfo, error } // Extract and sort chapter numbers - var chapters []int - for chapterStr := range data { - chapterNum, err := strconv.Atoi(chapterStr) + var chapters []ChapterInfo + + for raw := range data { + number, err := common.ParseChapterNumber(raw) if err != nil { + log.Debug( + "Skipping invalid Anime-Sama chapter: %q\n", + raw, + ) continue } - chapters = append(chapters, chapterNum) + + chapters = append(chapters, ChapterInfo{ + Number: number, + Raw: raw, + }) } - sort.Ints(chapters) + sort.Slice(chapters, func(i, j int) bool { + return chapters[i].Number.Compare( + chapters[j].Number, + ) < 0 + }) return &ScanInfo{ MangaName: mangaName, From 03d2c80884e7f30f1e6bd907df4ba9c337503ea8 Mon Sep 17 00:00:00 2001 From: EliasLd Date: Wed, 23 Sep 2026 15:54:23 +0200 Subject: [PATCH 09/11] feat(mangadex): handle decimal chapters --- internal/source/mangafreak/scraper/scan_info.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/source/mangafreak/scraper/scan_info.go b/internal/source/mangafreak/scraper/scan_info.go index 2a61d65..e63c179 100644 --- a/internal/source/mangafreak/scraper/scan_info.go +++ b/internal/source/mangafreak/scraper/scan_info.go @@ -5,11 +5,11 @@ import ( "io" "net/http" "net/url" - "strconv" "strings" "time" "github.com/EliasLd/goweeb/internal/logger" + "github.com/EliasLd/goweeb/internal/source/common" "github.com/PuerkitoBio/goquery" ) @@ -19,7 +19,7 @@ type ScanInfo struct { } type ChapterInfo struct { - Number int + Number common.ChapterNumber Label string URL string } @@ -102,7 +102,9 @@ func GetScanInfo( return } - chapterNumber, err := strconv.Atoi(fields[1]) + chapterNumber, err := common.ParseChapterNumber( + strings.TrimRight(fields[1], ":"), + ) if err != nil { return } From b2584be2e9d9a358721b3e02c2f91bc3695bfa12 Mon Sep 17 00:00:00 2001 From: EliasLd Date: Wed, 23 Sep 2026 15:55:09 +0200 Subject: [PATCH 10/11] fix: use new chapter format in main workflow Includes decimal chapters in both pdf & ebook modes --- internal/app/downloader.go | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/internal/app/downloader.go b/internal/app/downloader.go index c5eb095..8b41a0d 100644 --- a/internal/app/downloader.go +++ b/internal/app/downloader.go @@ -137,16 +137,15 @@ func downloadEntries( return } - maxNum := 0 + digits := 3 for _, entry := range entries { - if entry.Number > maxNum { - maxNum = entry.Number - } + digits = max( + digits, + entry.Number.IntegerDigits(), + ) } - digits := chapterDigits(maxNum) - if opts.EbookFriendly { if err := os.MkdirAll( opts.ScanDir, @@ -162,10 +161,7 @@ func downloadEntries( } for _, entry := range entries { - chStr := fmt.Sprintf( - "%d", - entry.Number, - ) + chStr := entry.Number.String() log.Info( "Downloading %s...\n", @@ -205,10 +201,9 @@ func downloadEntries( entryDir := filepath.Join( opts.ScanDir, fmt.Sprintf( - "%s %0*d", + "%s %s", prefix, - digits, - entry.Number, + entry.Number.Padded(digits), ), ) From d35e50e1605006953dce70afc427d002337d825a Mon Sep 17 00:00:00 2001 From: EliasLd Date: Wed, 23 Sep 2026 15:56:21 +0200 Subject: [PATCH 11/11] fix(viewRangeSelection): cropped chapters list when too many entries --- internal/tui/view.go | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/internal/tui/view.go b/internal/tui/view.go index 56bc264..b48d6d3 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -167,10 +167,17 @@ func viewRangeSelection(m Model) string { b.WriteString(labelStyle.Render("Available chapters")) b.WriteString("\n") + rangeWidth := 76 + if m.Width > 0 { + rangeWidth = min(rangeWidth, max(1, m.Width-4)) + } + b.WriteString( lipgloss.NewStyle(). Faint(true). - Render(m.AvailableRanges), + Render( + wrapAvailableRanges(m.AvailableRanges, rangeWidth), + ), ) b.WriteString("\n\n") @@ -283,3 +290,33 @@ func viewRangeSelection(m Model) string { b.String(), ) } + +func wrapAvailableRanges(ranges string, width int) string { + var lines []string + current := "" + + for _, part := range strings.Split(ranges, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + next := part + if current != "" { + next = current + ", " + part + } + + if current != "" && lipgloss.Width(next) > width { + lines = append(lines, current) + current = part + } else { + current = next + } + } + + if current != "" { + lines = append(lines, current) + } + + return strings.Join(lines, "\n") +}