]> git.lizzy.rs Git - go-anidb.git/commitdiff
misc: refactor, add tests, add list simplification method
authorDiogo Franco (Kovensky) <diogomfranco@gmail.com>
Sat, 13 Jul 2013 01:56:39 +0000 (22:56 -0300)
committerDiogo Franco (Kovensky) <diogomfranco@gmail.com>
Sat, 13 Jul 2013 01:56:39 +0000 (22:56 -0300)
misc/episode.go
misc/episode_test.go [new file with mode: 0644]
misc/episodelist.go [new file with mode: 0644]
misc/episodelist_test.go [new file with mode: 0644]
misc/episoderange.go [new file with mode: 0644]
misc/episoderange_test.go [new file with mode: 0644]

index 4a4077d4fd208e7bd85d4b7b935321c51fb9fc8e..1103952d745488ec3111320e2c1b5f8d494581be 100644 (file)
@@ -2,8 +2,8 @@ package misc
 
 import (
        "fmt"
+       "math"
        "strconv"
-       "strings"
 )
 
 type EpisodeContainer interface {
@@ -12,6 +12,7 @@ type EpisodeContainer interface {
 }
 
 type Formatter interface {
+       // Returns a string where the number portion is 0-padded to fit 'width' digits
        Format(width int) string
 }
 
@@ -71,14 +72,27 @@ type Episode struct {
 
 // 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
@@ -90,155 +104,13 @@ func (ep *Episode) Format(width int) string {
 
 // 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
-}
diff --git a/misc/episode_test.go b/misc/episode_test.go
new file mode 100644 (file)
index 0000000..373e805
--- /dev/null
@@ -0,0 +1,43 @@
+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>
+}
diff --git a/misc/episodelist.go b/misc/episodelist.go
new file mode 100644 (file)
index 0000000..877ac4a
--- /dev/null
@@ -0,0 +1,124 @@
+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]
+}
diff --git a/misc/episodelist_test.go b/misc/episodelist_test.go
new file mode 100644 (file)
index 0000000..753a4da
--- /dev/null
@@ -0,0 +1,31 @@
+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
+}
diff --git a/misc/episoderange.go b/misc/episoderange.go
new file mode 100644 (file)
index 0000000..d78bc02
--- /dev/null
@@ -0,0 +1,218 @@
+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],
+       }
+}
diff --git a/misc/episoderange_test.go b/misc/episoderange_test.go
new file mode 100644 (file)
index 0000000..a756746
--- /dev/null
@@ -0,0 +1,13 @@
+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
+}