import (
"fmt"
+ "math"
"strconv"
- "strings"
)
type EpisodeContainer interface {
}
type Formatter interface {
+ // Returns a string where the number portion is 0-padded to fit 'width' digits
Format(width int) string
}
// Converts the Episode into AniDB API episode format.
func (ep *Episode) String() string {
- return fmt.Sprintf("%s%d", ep.Type, ep.Number)
+ return ep.Format(1)
}
-// Returns true if ec is an Episode and is identical to this episode.
+// returns how many digits are needed to represent this episode
+func (ep *Episode) scale() int {
+ if ep == nil {
+ return 1
+ }
+ return 1 + int(math.Floor(math.Log10(float64(ep.Number))))
+}
+
+// Returns true if ec is an Episode and is identical to this episode,
+// or if ec is a single episode EpisodeRange / EpisodeList that
+// contain only this episode.
func (ep *Episode) ContainsEpisodes(ec EpisodeContainer) bool {
switch e := ec.(type) {
case *Episode:
return ep != nil && ep.Type == e.Type && ep.Number == ep.Number
+ case *EpisodeRange:
+ case *EpisodeList:
+ return EpisodeList{&EpisodeRange{Type: ep.Type, Start: ep, End: ep}}.ContainsEpisodes(ep)
default:
}
return false
// Parses a string in the usual AniDB API episode format and converts into
// an Episode.
-//
-// ParseEpisode("1") <=> &Episode{Type: EpisodeTypeRegular, Number: 1}
-// ParseEpisode("S2") <=> &Episode{Type: EpisodeTypeSpecial, Number: 2}
-// ParseEpisode("03") <=> &Episode{Type: EpisodeTypeRegular, Number: 3}
-// ParseEpisode("") <=> nil // invalid number
func ParseEpisode(s string) *Episode {
if no, err := strconv.ParseInt(s, 10, 32); err == nil {
return &Episode{Type: EpisodeTypeRegular, Number: int(no)}
+ } else if len(s) < 1 {
+ // s too short
} else if no, err = strconv.ParseInt(s[1:], 10, 30); err == nil {
return &Episode{Type: parseEpisodeType(s[:1]), Number: int(no)}
}
return nil
}
-
-// A range of episodes with a start and possibly without an end.
-type EpisodeRange struct {
- Type EpisodeType // Must be equal to both the Start and End types, unless End is nil
- Start *Episode // The start of the range
- End *Episode // The end of the range; may be nil, which represents an endless range
-}
-
-// Converts the EpisodeRange into AniDB API range format.
-func (ei *EpisodeRange) String() string {
- if ei.End == nil || ei.Start == ei.End || *(ei.Start) == *(ei.End) {
- return ei.Start.String()
- }
- return fmt.Sprintf("%s-%s", ei.Start, ei.End)
-}
-
-// If ec is an Episode, returns true if the Episode is of the same type as the range
-// and has a Number >= Start.Number; if End is defined, then the episode's Number must
-// also be <= End.Number.
-//
-// If ec is an EpisodeRange, returns true if they are both of the same type and
-// the ec's Start.Number is >= this range's Start.Number;
-// also returns true if this EpisodeRange is unbounded or if the ec is bounded
-// and ec's End.Number is <= this range's End.Number.
-//
-// Returns false otherwise.
-func (er *EpisodeRange) ContainsEpisodes(ec EpisodeContainer) bool {
- if er == nil {
- return false
- }
- if er.Start == nil || er.Start.Type != e.Type ||
- (er.End != nil && er.End.Type != e.Type) {
- panic("Invalid EpisodeRange used")
- }
-
- switch e := ec.(type) {
- case *Episode:
- if e.Type == er.Type && e.Number >= er.Start.Number {
- if er.End == nil {
- return true
- } else if e.Number <= er.End.Number {
- return true
- }
- }
- case *EpisodeRange:
- if e.Type == er.Type {
- if e.Start.Number >= er.Start.Number {
- if er.End == nil {
- return true
- } else if e.End == nil {
- return false // a finite set can't contain an infinite one
- } else if e.End.Number <= er.End.Number {
- return true
- }
- }
- }
- default:
- }
- return false
-}
-
-// Parses a string in the AniDB API range format and converts into an EpisodeRange.
-//
-// ParseEpisodeRange("1") <=> ep := ParseEpisode("1");
-// &EpisodeRange{Type: EpisodeTypeRegular, Start: ep, End: ep}
-// ParseEpisodeRange("S1-") <=>
-// &EpisodeRange{Type: EpisodeTypeSpecial, Start: ParseEpisode("S1")}
-// ParseEpisodeRange("T1-T3") <=>
-// &EpisodeRange{Type: EpisodeTypeTrailer, Start: ParseEpisode("T1"), End: ParseEpisode("T3")}
-// ParseEpisodeRange("5-S3") <=> nil // different episode types in range
-// ParseEpisodeRange("") <=> nil // invalid start of range
-func ParseEpisodeRange(s string) *EpisodeRange {
- parts := strings.Split(s, "-")
- if len(parts) > 2 {
- return nil
- }
-
- eps := [2]*Episode{}
- for i := range parts {
- eps[i] = ParseEpisode(parts[i])
- }
- if eps[0] == nil {
- return nil
- }
-
- // Not an interval (just "epno") --
- // convert into interval starting and ending in the same episode
- if len(parts) == 1 {
- eps[1] = eps[0]
- }
-
- if len(parts) > 1 && eps[1] != nil && eps[0].Type != eps[1].Type {
- return nil
- }
- return &EpisodeRange{
- Type: eps[0].Type,
- Start: eps[0],
- End: eps[1],
- }
-}
-
-type EpisodeList []*EpisodeRange
-
-// Converts the EpisodeList into the AniDB API list format.
-func (el EpisodeList) String() string {
- parts := make([]string, len(el))
- for i, er := range el {
- parts[i] = er.String()
- }
- return strings.Join(parts, ",")
-}
-
-// Returns true if any of the contained EpisodeRanges contain the
-// given EpisodeContainer.
-func (el EpisodeList) ContainsEpisodes(ec EpisodeContainer) bool {
- for _, i := range el {
- if i != nil && i.ContainsEpisodes(ec) {
- return true
- }
- }
- return false
-}
-
-// Parses a string in the AniDB API list format and converts into
-// an EpisodeList.
-//
-// ParseEpisodeList("01") <=> EpisodeList{ParseEpisodeRange("01")}
-// ParseEpisodeList("S2-S3") <=> EpisodeList{ParseEpisodeRange("S2-S3")}
-// ParseEpisodeList("T1,C1-C3") <=> EpisodeList{ParseEpisodeRange("T1"), ParseEpisodeRange("C1-C3")}
-func ParseEpisodeList(s string) (el EpisodeList) {
- parts := strings.Split(s, ",")
-
- el = make(EpisodeList, len(parts))
- for i := range parts {
- el[i] = ParseEpisodeRange(parts[i])
- }
-
- return
-}
--- /dev/null
+package misc_test
+
+import (
+ "fmt"
+ "github.com/Kovensky/go-anidb/misc"
+)
+
+func ExampleParseEpisode() {
+ fmt.Printf("%#v\n", misc.ParseEpisode("1"))
+ fmt.Printf("%#v\n", misc.ParseEpisode("S2"))
+ fmt.Printf("%#v\n", misc.ParseEpisode("03"))
+ fmt.Printf("%#v\n", misc.ParseEpisode("")) // invalid episode
+
+ // Output:
+ // &misc.Episode{Type:1, Number:1}
+ // &misc.Episode{Type:2, Number:2}
+ // &misc.Episode{Type:1, Number:3}
+ // (*misc.Episode)(nil)
+}
+
+// ParseEpisodeRange("1") <=> ep := ParseEpisode("1");
+// &EpisodeRange{Type: EpisodeTypeRegular, Start: ep, End: ep}
+// ParseEpisodeRange("S1-") <=>
+// &EpisodeRange{Type: EpisodeTypeSpecial, Start: ParseEpisode("S1")}
+// ParseEpisodeRange("T1-T3") <=>
+// &EpisodeRange{Type: EpisodeTypeTrailer, Start: ParseEpisode("T1"), End: ParseEpisode("T3")}
+// ParseEpisodeRange("5-S3") <=> nil // different episode types in range
+// ParseEpisodeRange("") <=> nil // invalid start of range
+
+func ExampleParseEpisodeRange() {
+ fmt.Println(misc.ParseEpisodeRange("01"))
+ fmt.Println(misc.ParseEpisodeRange("S1-")) // endless range
+ fmt.Println(misc.ParseEpisodeRange("T1-T3"))
+ fmt.Println(misc.ParseEpisodeRange("5-S3")) // different episode types in range
+ fmt.Println(misc.ParseEpisodeRange("")) // invalid start of range
+
+ // Output:
+ // 1
+ // S1-
+ // T1-T3
+ // <nil>
+ // <nil>
+}
--- /dev/null
+package misc
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+)
+
+type EpisodeList []*EpisodeRange
+
+// Converts the EpisodeList into the AniDB API list format.
+func (el EpisodeList) String() string {
+ scales := map[EpisodeType]int{}
+
+ for _, er := range el {
+ if er == nil {
+ continue
+ }
+
+ s := er.scale()
+ if s > scales[er.Type] {
+ scales[er.Type] = s
+ }
+ }
+
+ parts := make([]string, len(el))
+ for i, er := range el {
+ parts[i] = er.Format(scales[er.Type])
+ }
+
+ return strings.Join(parts, ",")
+}
+
+// Returns true if any of the contained EpisodeRanges contain the
+// given EpisodeContainer.
+func (el EpisodeList) ContainsEpisodes(ec EpisodeContainer) bool {
+ for _, i := range el {
+ if i != nil && i.ContainsEpisodes(ec) {
+ return true
+ }
+ }
+ return false
+}
+
+// Parses a string in the AniDB API list format and converts into
+// an EpisodeList.
+//
+// ParseEpisodeList("01") <=> EpisodeList{ParseEpisodeRange("01")}
+// ParseEpisodeList("S2-S3") <=> EpisodeList{ParseEpisodeRange("S2-S3")}
+// ParseEpisodeList("T1,C1-C3") <=> EpisodeList{ParseEpisodeRange("T1"), ParseEpisodeRange("C1-C3")}
+func ParseEpisodeList(s string) (el EpisodeList) {
+ parts := strings.Split(s, ",")
+
+ el = make(EpisodeList, len(parts))
+ for i := range parts {
+ el[i] = ParseEpisodeRange(parts[i])
+ }
+
+ return
+}
+
+// Returns a simplified version of the EpisodeList (removes nil ranges, merges mergeable ranges, sorts).
+func (el EpisodeList) Simplify() EpisodeList {
+ nl := make(EpisodeList, 0, len(el))
+
+ // drop nil ranges
+ for _, er := range el {
+ if er != nil {
+ nl = append(nl, er)
+ }
+ }
+
+ // merge ranges
+ for n, changed := 0, true; changed; n++ {
+ tmp := EpisodeList{}
+ used := map[int]bool{}
+ changed = false
+
+ for i, a := range nl {
+ if used[i] {
+ continue
+ }
+ for j, b := range nl[i+1:] {
+ if c := a.Merge(b); c != nil {
+ changed = true
+ used[j+i+1] = true
+ a = c
+ }
+ }
+ tmp = append(tmp, a)
+ }
+ nl = tmp
+
+ if n > len(el) {
+ panic(fmt.Sprintf("Too many iterations (%d) when simplifing %s!", n, el))
+ }
+ }
+ sort.Sort(nl)
+ return nl
+}
+
+func (el EpisodeList) Len() int {
+ return len(el)
+}
+
+func (el EpisodeList) Less(i, j int) bool {
+ switch {
+ case el[i] == nil:
+ return true
+ case el[j] == nil:
+ return false
+ case el[i].Type < el[j].Type:
+ return true
+ case el[i].Type > el[j].Type:
+ return false
+ case el[i].Start.Number < el[j].Start.Number:
+ return true
+ }
+ return false
+}
+
+func (el EpisodeList) Swap(i, j int) {
+ el[i], el[j] = el[j], el[i]
+}
--- /dev/null
+package misc_test
+
+import (
+ "fmt"
+ "github.com/Kovensky/go-anidb/misc"
+)
+
+func ExampleEpisodeRange_Merge() {
+ a := misc.ParseEpisodeRange("5-7")
+ b := misc.ParseEpisodeRange("8-12")
+ fmt.Println(a.Merge(b)) // 5-7 + 8-12
+
+ b = misc.ParseEpisodeRange("3-6")
+ fmt.Println(a.Merge(b)) // 5-7 + 3-6
+
+ b = misc.ParseEpisodeRange("10-12")
+ fmt.Println(a.Merge(b)) // 5-7 + 10-12 (invalid, not touching)
+
+ b = misc.ParseEpisodeRange("S1-S3")
+ fmt.Println(a.Merge(b)) // 5-7 + S1-S3 (invalid, different types)
+
+ a = misc.ParseEpisodeRange("S3-S10")
+ fmt.Println(a.Merge(b)) // S3-S10 + S1-S3
+
+ // Output:
+ // 05-12
+ // 3-7
+ // <nil>
+ // <nil>
+ // S01-S10
+}
--- /dev/null
+package misc
+
+import (
+ "fmt"
+ "strings"
+)
+
+// A range of episodes with a start and possibly without an end.
+type EpisodeRange struct {
+ Type EpisodeType // Must be equal to both the Start and End types; if End is nil, must be equal to the Start type
+ Start *Episode // The start of the range
+ End *Episode // The end of the range; may be nil, which represents an endless range
+}
+
+// Converts the EpisodeRange into AniDB API range format.
+func (er *EpisodeRange) String() string {
+ return er.Format(er.scale())
+}
+
+func (er *EpisodeRange) Format(width int) string {
+ if er.Start == er.End || (er.End != nil && *(er.Start) == *(er.End)) {
+ return er.Start.Format(width)
+ }
+
+ if er.End == nil {
+ return fmt.Sprintf("%s-", er.Start.Format(width))
+ }
+ return fmt.Sprintf("%s-%s", er.Start.Format(width), er.End.Format(width))
+}
+
+func (er *EpisodeRange) scale() int {
+ if er == nil {
+ return 1
+ }
+ s, e := er.Start.scale(), er.End.scale()
+ if e > s {
+ return e
+ }
+ return s
+}
+
+// If ec is an *Episode, returns true if the Episode is of the same type as the range
+// and has a Number >= Start.Number; if End is defined, then the episode's Number must
+// also be <= End.Number.
+//
+// If ec is an *EpisodeRange, returns true if they are both of the same type and
+// the ec's Start.Number is >= this range's Start.Number;
+// also returns true if this EpisodeRange is unbounded or if the ec is bounded
+// and ec's End.Number is <= this range's End.Number.
+//
+// If ec is an EpisodeList, returns true if all listed EpisodeRanges are contained
+// by this EpisodeRange.
+//
+// Returns false otherwise.
+func (er *EpisodeRange) ContainsEpisodes(ec EpisodeContainer) bool {
+ if er == nil {
+ return false
+ }
+ if er.Start == nil || er.Start.Type != er.Type ||
+ (er.End != nil && er.End.Type != er.Type) {
+ panic("Invalid EpisodeRange used")
+ }
+
+ switch e := ec.(type) {
+ case *Episode:
+ if e.Type == er.Type && e.Number >= er.Start.Number {
+ if er.End == nil {
+ return true
+ } else if e.Number <= er.End.Number {
+ return true
+ }
+ }
+ case *EpisodeRange:
+ if e.Type == er.Type {
+ if e.Start.Number >= er.Start.Number {
+ if er.End == nil {
+ return true
+ } else if e.End == nil {
+ return false // a finite set can't contain an infinite one
+ } else if e.End.Number <= er.End.Number {
+ return true
+ }
+ }
+ }
+ case EpisodeList:
+ for _, ec := range e {
+ if !er.ContainsEpisodes(ec) {
+ return false
+ }
+ }
+ return true
+ default:
+ }
+ return false
+}
+
+// Tries to merge a with b, returning a new *EpisodeRange that's
+// a superset of both a and b.
+//
+// Returns nil if a and b don't intersect, or are not adjacent.
+func (a *EpisodeRange) Merge(b *EpisodeRange) (c *EpisodeRange) {
+ if a.touches(b) {
+ c = &EpisodeRange{Type: a.Type}
+
+ if a.Start.Number <= b.Start.Number {
+ c.Start = a.Start
+ } else {
+ c.Start = b.Start
+ }
+
+ switch {
+ case a.End == nil || b.End == nil:
+ c.End = nil
+ case a.End.Number >= b.End.Number:
+ c.End = a.End
+ default:
+ c.End = b.End
+ }
+ }
+ return
+}
+
+// Returns true if both ranges are of the same type and
+// have identical start/end positions
+func (a *EpisodeRange) Equals(b *EpisodeRange) bool {
+ if a == b { // pointers to the same thing
+ return true
+ }
+ if a == nil || b == nil {
+ return false
+ }
+
+ if a.Type == b.Type {
+ if a.End == b.End || (a.End != nil && b.End != nil && a.End.Number == b.End.Number) {
+ if a.Start == b.Start || a.Start.Number == b.Start.Number {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func (a *EpisodeRange) touches(b *EpisodeRange) bool {
+ if a == nil || b == nil || a.Type != b.Type {
+ return false
+ }
+
+ switch {
+ case a.End == nil:
+ switch {
+ case b.End == nil:
+ // both infinite
+ return true
+
+ case b.End.Number >= a.Start.Number-1:
+ // {b [ } a ...
+ // start-1 so it's still true when they're only adjacent
+ return true
+ }
+
+ case b.End == nil:
+ switch {
+ case a.End.Number >= b.Start.Number-1:
+ // [a { ] b ...
+ return true
+ }
+
+ case a.Start.Number == b.Start.Number || a.End.Number == b.End.Number:
+ // touching
+ return true
+
+ case a.End.Number < b.End.Number:
+ switch {
+ case a.End.Number >= b.Start.Number-1:
+ // [a { ] b}
+ return true
+ }
+
+ case b.End.Number < a.End.Number:
+ switch {
+ case b.End.Number >= a.Start.Number-1:
+ // {b [ } a]
+ return true
+ }
+ }
+ return false
+}
+
+// Parses a string in the AniDB API range format and converts into an EpisodeRange.
+func ParseEpisodeRange(s string) *EpisodeRange {
+ parts := strings.Split(s, "-")
+ if len(parts) > 2 {
+ return nil
+ }
+
+ eps := [2]*Episode{}
+ for i := range parts {
+ eps[i] = ParseEpisode(parts[i])
+ }
+ if eps[0] == nil {
+ return nil
+ }
+
+ // Not an interval (just "epno") --
+ // convert into interval starting and ending in the same episode
+ if len(parts) == 1 {
+ eps[1] = eps[0]
+ }
+
+ if len(parts) > 1 && eps[1] != nil && eps[0].Type != eps[1].Type {
+ return nil
+ }
+ return &EpisodeRange{
+ Type: eps[0].Type,
+ Start: eps[0],
+ End: eps[1],
+ }
+}
--- /dev/null
+package misc_test
+
+import (
+ "fmt"
+ "github.com/Kovensky/go-anidb/misc"
+)
+
+func ExampleEpisodeList_Simplify() {
+ a := misc.ParseEpisodeList("1,2,3,5,10-14,13-15,,S3-S6,C7-C10,S1,S7,S8-")
+ fmt.Println(a.Simplify())
+
+ // Output: 01-03,05,10-15,S1,S3-,C07-C10
+}