diff --git a/.travis.yml b/.travis.yml index befcc07..5b0acbf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ language: go go: - - "1.10" + - "1.13" script: - make clean diff --git a/Makefile b/Makefile index 47f0989..7032aa4 100644 --- a/Makefile +++ b/Makefile @@ -9,8 +9,8 @@ clean: rm -rfv build deps: - go get github.com/tcnksm/ghr - go get github.com/mholt/archiver/cmd/arc + GO111MODULE=on go install github.com/tcnksm/ghr + GO111MODULE=on go install github.com/mholt/archiver/cmd/arc test: go test ./... diff --git a/README.md b/README.md index 00907c3..93cb266 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,10 @@ # koboutils -Short utilities to do stuff with Kobo eReaders. +Utilities to do stuff with Kobo eReaders. Can be used as a library: godoc.org/github.com/geek1011/koboutils/v2/kobo. -Also can be used as a library: godoc.org/github.com/geek1011/koboutils/kobo \ No newline at end of file +**Features:** +- Full support for device codenames (class, family, secondary). +- Device specs. +- Upgrade checks. +- Device detection. +- Cover image resizing. +- Firmware version and date extraction. diff --git a/go.mod b/go.mod index 5cbebff..0e4c3c7 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,5 @@ -module github.com/geek1011/koboutils +module github.com/geek1011/koboutils/v2 -go 1.12 +go 1.13 require github.com/spf13/pflag v1.0.5 diff --git a/kobo-find/main.go b/kobo-find/main.go index 270ae49..2006435 100644 --- a/kobo-find/main.go +++ b/kobo-find/main.go @@ -4,7 +4,7 @@ import ( "fmt" "os" - "github.com/geek1011/koboutils/kobo" + "github.com/geek1011/koboutils/v2/kobo" "github.com/spf13/pflag" ) diff --git a/kobo-info/main.go b/kobo-info/main.go index 7e1850a..c882246 100644 --- a/kobo-info/main.go +++ b/kobo-info/main.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/geek1011/koboutils/kobo" + "github.com/geek1011/koboutils/v2/kobo" "github.com/spf13/pflag" ) @@ -56,9 +56,11 @@ func main() { } if device, ok := kobo.DeviceByID(id); ok { - printkv("Device", device.Name) + printkv("Device", device.Name()) printkv("Device ID", id) - printkv("Hardware", device.Hardware) + printkv("Device Family", fmt.Sprintf("%s (%s)", device.Family(), device.CodeNames().Family())) + printkv("Codenames", device.CodeNames().String()) + printkv("Hardware", device.Hardware().String()) } else { printkv("Device", "unknown") printkv("Device ID", id) @@ -96,7 +98,7 @@ func printkv(key, value string) { } fmt.Printf(` "%s": "%s"`, strings.Replace(strings.ToLower(key), " ", "_", -1), value) } else { - fmt.Printf("%10s: %s\n", key, value) + fmt.Printf("%15s: %s\n", key, value) } } diff --git a/kobo/device.go b/kobo/device.go index b6ec7e5..7d11aec 100644 --- a/kobo/device.go +++ b/kobo/device.go @@ -1,42 +1,412 @@ +// Package kobo contains stuff related to Kobo devices, firmware, and nickel. package kobo -// Device represents a device. -type Device struct { - ID string - Name string - Hardware string -} - -// Devices. -var ( - DeviceTouchAB = Device{"00000000-0000-0000-0000-000000000310", "Kobo Touch A/B", "kobo3"} - DeviceTouchC = Device{"00000000-0000-0000-0000-000000000320", "Kobo Touch C", "kobo4"} - DeviceMini = Device{"00000000-0000-0000-0000-000000000340", "Kobo Mini", "kobo4"} - DeviceGlo = Device{"00000000-0000-0000-0000-000000000330", "Kobo Glo", "kobo4"} - DeviceGloHD = Device{"00000000-0000-0000-0000-000000000371", "Kobo Glo HD", "kobo6"} - DeviceTouch2 = Device{"00000000-0000-0000-0000-000000000372", "Kobo Touch 2.0", "kobo6"} - DeviceAura = Device{"00000000-0000-0000-0000-000000000360", "Kobo Aura", "kobo5"} - DeviceAuraHD = Device{"00000000-0000-0000-0000-000000000350", "Kobo Aura HD", "kobo4"} - DeviceAuraH2O = Device{"00000000-0000-0000-0000-000000000370", "Kobo Aura H2O", "kobo5"} - DeviceAuraH2OEdition2v1 = Device{"00000000-0000-0000-0000-000000000374", "Kobo Aura H2O Edition 2 v1", "kobo6"} - DeviceAuraH2OEdition2v2 = Device{"00000000-0000-0000-0000-000000000378", "Kobo Aura H2O Edition 2 v2", "kobo7"} - DeviceAuraONE = Device{"00000000-0000-0000-0000-000000000373", "Kobo Aura ONE", "kobo6"} - DeviceAuraONELimitedEdition = Device{"00000000-0000-0000-0000-000000000381", "Kobo Aura ONE Limited Edition", "kobo6"} - DeviceAuraEdition2v1 = Device{"00000000-0000-0000-0000-000000000375", "Kobo Aura Edition 2 v1", "kobo6"} - DeviceAuraEdition2v2 = Device{"00000000-0000-0000-0000-000000000379", "Kobo Aura Edition 2 v2", "kobo7"} - DeviceClaraHD = Device{"00000000-0000-0000-0000-000000000376", "Kobo Clara HD", "kobo7"} - DeviceForma = Device{"00000000-0000-0000-0000-000000000377", "Kobo Forma", "kobo7"} - DeviceForma32 = Device{"00000000-0000-0000-0000-000000000380", "Kobo Forma 32GB", "kobo7"} - DeviceLibraH2O = Device{"00000000-0000-0000-0000-000000000384", "Kobo Libra H2O", "kobo7"} - Devices = []Device{DeviceTouchAB, DeviceTouchC, DeviceMini, DeviceGlo, DeviceGloHD, DeviceTouch2, DeviceAura, DeviceAuraHD, DeviceAuraH2O, DeviceAuraH2OEdition2v1, DeviceAuraH2OEdition2v2, DeviceAuraONE, DeviceAuraONELimitedEdition, DeviceAuraEdition2v1, DeviceAuraEdition2v2, DeviceForma, DeviceForma32, DeviceLibraH2O} +import ( + "fmt" + "image" ) -// DeviceByID gets the device by the ID. -func DeviceByID(id string) (*Device, bool) { - for _, device := range Devices { - if device.ID == id { - return &device, true +// See https://gist.github.com/geek1011/613b34c23f026f7c39c50ee32f5e167e and +// https://github.com/shermp/Kobo-UNCaGED/issues/16 + +// Device is a device model. +type Device int + +// Hardware is a hardware revision. +type Hardware int + +// CodeNames are used to identify the device category, devices, and variations. +type ( + // CodeName represents an individual codename. Note that a codename can be + // used for more than one thing in a triplet. + CodeName string + + // CodeNameTriplet represents a triplet of class/family/secondary codenames. + // Note that nothing in nickel says a device can only have 3, but everything + // so far implies that (and it makes sense). + CodeNameTriplet [3]CodeName +) + +// CoverType is used to identify different cover dimensions used for different +// purposes by nickel. +type CoverType string + +// Devices (not including really old ones, like Kobo eReader, Wireless, Literati, and Vox). +const ( + DeviceTouchAB Device = 310 + DeviceTouchC Device = 320 + DeviceGlo Device = 330 + DeviceMini Device = 340 + DeviceAuraHD Device = 350 + DeviceAura Device = 360 + DeviceAuraH2O Device = 370 + DeviceGloHD Device = 371 + DeviceTouch2 Device = 372 + DeviceAuraONE Device = 373 + DeviceAuraH2OEdition2v1 Device = 374 + DeviceAuraEdition2v1 Device = 375 + DeviceClaraHD Device = 376 + DeviceForma Device = 377 + DeviceAuraH2OEdition2v2 Device = 378 + DeviceAuraEdition2v2 Device = 379 + DeviceForma32 Device = 380 + DeviceAuraONELimitedEdition Device = 381 + DeviceLibraH2O Device = 384 +) + +// Hardware revisions. +const ( + HardwareKobo3 Hardware = 3 + HardwareKobo4 Hardware = 4 + HardwareKobo5 Hardware = 5 + HardwareKobo6 Hardware = 6 + HardwareKobo7 Hardware = 7 +) + +// Codenames. +const ( + CodeNameNone CodeName = "" + CodeNameDesktop CodeName = "desktop" + CodeNameNickel1 CodeName = "nickel1" + CodeNameNickel2 CodeName = "nickel2" + CodeNameMerch CodeName = "merch" + CodeNameVox CodeName = "vox" + CodeNameTrilogy CodeName = "trilogy" + CodeNamePixie CodeName = "pixie" + CodeNamePika CodeName = "pika" + CodeNameDragon CodeName = "dragon" + CodeNameDahlia CodeName = "dahlia" + CodeNameAlyssum CodeName = "alyssum" + CodeNameSnow CodeName = "snow" + CodeNameNova CodeName = "nova" + CodeNameStorm CodeName = "storm" + CodeNameDaylight CodeName = "daylight" + CodeNameSuperDaylight CodeName = "superDaylight" + CodeNameFrost CodeName = "frost" + CodeNameFrost32 CodeName = "frost32" + CodeNamePhoenix CodeName = "phoenix" + CodeNameKraken CodeName = "kraken" + CodeNameStar CodeName = "star" +) + +// Cover types. +const ( + CoverTypeFull CoverType = "N3_FULL" + CoverTypeLibFull CoverType = "N3_LIBRARY_FULL" + CoverTypeLibList CoverType = "N3_LIBRARY_LIST" + CoverTypeLibGrid CoverType = "N3_LIBRARY_GRID" +) + +// Devices returns a slice of all supported devices. +func Devices() []Device { + return []Device{DeviceTouchAB, DeviceTouchC, DeviceGlo, DeviceMini, DeviceAuraHD, DeviceAura, DeviceAuraH2O, DeviceGloHD, DeviceTouch2, DeviceAuraONE, DeviceAuraH2OEdition2v1, DeviceAuraEdition2v1, DeviceClaraHD, DeviceForma, DeviceAuraH2OEdition2v2, DeviceAuraEdition2v2, DeviceForma32, DeviceAuraONELimitedEdition, DeviceLibraH2O} +} + +// CoverTypes returns a slice of all implemented nickel cover types. +func CoverTypes() []CoverType { + return []CoverType{CoverTypeFull, CoverTypeLibFull, CoverTypeLibList, CoverTypeLibGrid} +} + +// DeviceByID gets a device by its full ID string. +func DeviceByID(id string) (Device, bool) { + for _, device := range Devices() { + if device.IDString() == id { + return device, true } } - return nil, false + return 0, false +} + +// ID returns the numerical device ID. +func (d Device) ID() int { + return int(d) +} + +// IDString returns the full ID string. +func (d Device) IDString() string { + return fmt.Sprintf("00000000-0000-0000-0000-%012d", d.ID()) +} + +func (d Device) String() string { + return d.Name() +} + +// Name returns the full device name. +func (d Device) Name() string { + cd := d.CodeNames() + dev := cd.FamilyString() + if sec := cd.SecondaryString(); sec != "" { + dev += " " + sec + } + switch d { + case DeviceTouchAB: + dev += " A/B" + case DeviceTouchC: + dev += " C" + case DeviceAuraEdition2v1, DeviceAuraEdition2v2: + dev += " Edition 2" + } + switch d { + case DeviceAuraEdition2v1, DeviceAuraH2OEdition2v1: + dev += " v1" + case DeviceAuraEdition2v2, DeviceAuraH2OEdition2v2: + dev += " v2" + } + return dev +} + +// Hardware returns the hardware revision. +func (d Device) Hardware() Hardware { + switch d { + case DeviceTouchAB: + return HardwareKobo3 + case DeviceTouchC, DeviceMini, DeviceGlo, DeviceAuraHD: + return HardwareKobo4 + case DeviceAura, DeviceAuraH2O: + return HardwareKobo5 + case DeviceGloHD, DeviceTouch2, DeviceAuraH2OEdition2v1, DeviceAuraONE, DeviceAuraONELimitedEdition, DeviceAuraEdition2v1: + return HardwareKobo6 + case DeviceAuraH2OEdition2v2, DeviceAuraEdition2v2, DeviceClaraHD, DeviceForma, DeviceForma32, DeviceLibraH2O: + return HardwareKobo7 + } + panic("unknown device") +} + +// Hardware returns the numerical hardware revision. +func (h Hardware) Hardware() int { + return int(h) +} + +func (h Hardware) String() string { + return fmt.Sprintf("kobo%d", int(h)) +} + +// Is replicates the Device::is* functions in libnickel. +func (d Device) Is(n CodeName) bool { + cn := d.CodeNames() + return n != CodeNameNone && (cn.Class() == n || cn.Family() == n || cn.Secondary() == n) +} + +// CodeNames returns the codename triplet for the device (like libnickel). Note: +// Nickel has a slightly different definition if Class, Family, and Secondary, +// but these triplets are correct (i.e. Device::is* will match nickel, and the +// hierachy is correct). These were determined by static analysis of libnickel. +// See PR#1 for details. +func (d Device) CodeNames() CodeNameTriplet { + switch d { + case DeviceTouchAB, DeviceTouchC: + return CodeNameTriplet{CodeNameTrilogy, CodeNameTrilogy, CodeNameNone} + case DeviceMini: + return CodeNameTriplet{CodeNameTrilogy, CodeNamePixie, CodeNameNone} + case DeviceTouch2: + return CodeNameTriplet{CodeNameTrilogy, CodeNamePika, CodeNameNone} + + case DeviceAuraHD: + return CodeNameTriplet{CodeNameDragon, CodeNameDragon, CodeNameNone} + case DeviceAuraH2O: + return CodeNameTriplet{CodeNameDragon, CodeNameDahlia, CodeNameNone} + case DeviceGloHD: + return CodeNameTriplet{CodeNameDragon, CodeNameAlyssum, CodeNameNone} + case DeviceAuraH2OEdition2v1, DeviceAuraH2OEdition2v2: + return CodeNameTriplet{CodeNameDragon, CodeNameSnow, CodeNameNone} + case DeviceClaraHD: + return CodeNameTriplet{CodeNameDragon, CodeNameNova, CodeNameNone} + case DeviceLibraH2O: + return CodeNameTriplet{CodeNameDragon, CodeNameStorm, CodeNameNone} + + case DeviceAuraONE: + return CodeNameTriplet{CodeNameDaylight, CodeNameDaylight, CodeNameNone} + case DeviceAuraONELimitedEdition: + return CodeNameTriplet{CodeNameDaylight, CodeNameDaylight, CodeNameSuperDaylight} + case DeviceForma: + return CodeNameTriplet{CodeNameDaylight, CodeNameFrost, CodeNameNone} + case DeviceForma32: + return CodeNameTriplet{CodeNameDaylight, CodeNameFrost, CodeNameFrost32} + + case DeviceAura: + return CodeNameTriplet{CodeNamePhoenix, CodeNamePhoenix, CodeNameNone} + case DeviceGlo: + return CodeNameTriplet{CodeNamePhoenix, CodeNameKraken, CodeNameNone} + case DeviceAuraEdition2v1, DeviceAuraEdition2v2: + return CodeNameTriplet{CodeNamePhoenix, CodeNameStar, CodeNameNone} + } + panic("unknown device") +} + +func (c CodeName) String() string { + return string(c) +} + +func (c CodeNameTriplet) String() string { + if c[2] != CodeNameNone { + return fmt.Sprintf("class=%s family=%s secondary=%s", c[0], c[1], c[2]) + } + return fmt.Sprintf("class=%s family=%s", c[0], c[1]) +} + +// Family is short for Device.CodeNames().FamilyString(). +func (d Device) Family() string { + return d.CodeNames().FamilyString() +} + +// Class gets the class/category. +func (c CodeNameTriplet) Class() CodeName { + return c[0] +} + +// Family gets the family/model (i.e. part of a class) +func (c CodeNameTriplet) Family() CodeName { + return c[1] +} + +// FamilyString gets the human readable family/model. +func (c CodeNameTriplet) FamilyString() string { + switch c.Family() { + case CodeNameDesktop: + return "Kobo Desktop" + case CodeNameNickel1: + return "Kobo eReader" + case CodeNameNickel2: + return "Kobo Wireless eReader" + case CodeNameMerch: + return "Literati / LookBook eReader" + case CodeNameVox: + return "Kobo Vox" + case CodeNameTrilogy: + return "Kobo Touch" + case CodeNamePixie: + return "Kobo Mini" + case CodeNamePika: + return "Kobo Touch 2.0" + case CodeNameDragon: + return "Kobo Aura HD" + case CodeNameDahlia: + return "Kobo Aura H2O" + case CodeNameAlyssum: + return "Kobo Glo HD" + case CodeNameSnow: + return "Kobo Aura H2O Edition 2" + case CodeNameNova: + return "Kobo Clara HD" + case CodeNameStorm: + return "Kobo Libra H2O" + case CodeNameDaylight: + return "Kobo Aura ONE" + case CodeNameFrost: + return "Kobo Forma" + case CodeNamePhoenix: + return "Kobo Aura" + case CodeNameKraken: + return "Kobo Glo" + case CodeNameStar: + return "Kobo Aura" + } + panic("unknown family") +} + +// Secondary gets the secondary device codename (i.e. refines the family). +func (c CodeNameTriplet) Secondary() CodeName { + return c[2] +} + +// SecondaryString returns the human readable string to append to FamilyString +// if applicable (e.g. Limited Edition, 32GB). +func (c CodeNameTriplet) SecondaryString() string { + switch c.Secondary() { + case CodeNameNone: + return "" + case CodeNameSuperDaylight: + return "Limited Edition" + case CodeNameFrost32: + return "32GB" + } + panic("unknown secondary") +} + +// CoverSize returns the cover size for a cover type for a Device. Currently, +// everything except for the Full cover is the same for every device. +func (d Device) CoverSize(t CoverType) image.Point { + if t == CoverTypeLibList { + return image.Pt(60, 90) + } else if t == CoverTypeLibGrid { + return image.Pt(149, 223) + } else if t == CoverTypeLibFull { + return image.Pt(355, 530) + } else if t != CoverTypeFull { + panic("unknown cover type") + } + + switch d.CodeNames().Family() { + case CodeNameDragon, CodeNameSnow: + return image.Pt(1080, 1440) + case CodeNameDahlia: + return image.Pt(1080, 1429) + case CodeNameAlyssum, CodeNameNova: + return image.Pt(1072, 1448) + case CodeNameStorm: + return image.Pt(1264, 1680) + case CodeNameDaylight: + return image.Pt(1404, 1872) + case CodeNameFrost: + return image.Pt(1440, 1920) + case CodeNamePhoenix: + return image.Pt(758, 1014) + case CodeNameKraken, CodeNameStar: + return image.Pt(758, 1024) + default: + return image.Pt(600, 800) + } +} + +// CoverSized returns a size resized to the correct size using the same logic as +// nickel. +func (d Device) CoverSized(t CoverType, orig image.Point) image.Point { + return t.Resize(d.CoverSize(t), orig) +} + +// NickelString returns the internal string used in nickel to identify the cover +// type. +func (c CoverType) NickelString() string { + return string(c) +} + +func (c CoverType) String() string { + return c.NickelString() +} + +// Resize returns the dimensions to resize sz to for the cover type and target size. +func (c CoverType) Resize(target image.Point, sz image.Point) image.Point { + switch c { + case CoverTypeFull: + return resizeKeepAspectRatio(sz, target, false) + case CoverTypeLibFull, CoverTypeLibGrid, CoverTypeLibList: + return resizeKeepAspectRatio(sz, target, true) + } + panic("unknown cover type") +} + +// GeneratePath generates the path for the cover of an ImageID. The path is always +// separated with forward slashes. +func (c CoverType) GeneratePath(external bool, iid string) string { + cdir := ".kobo-images" + if external { + cdir = "koboExtStorage/images-cache" + } + dir1, dir2, base := hashedImageParts(iid) + return fmt.Sprintf("%s/%s/%s/%s - %s.parsed", cdir, dir1, dir2, base, c.NickelString()) +} + +// StorageGB returns the advertised storage capacity of a Device. +func (d Device) StorageGB() int { + switch d { + case DeviceTouchAB, DeviceTouchC, DeviceMini: + return 2 + case DeviceTouch2, DeviceAuraHD, DeviceAuraH2O, DeviceGloHD, DeviceAura, DeviceGlo, DeviceAuraEdition2v1, DeviceAuraEdition2v2: + return 4 + case DeviceAuraH2OEdition2v1, DeviceAuraH2OEdition2v2, DeviceClaraHD, DeviceLibraH2O, DeviceAuraONE, DeviceForma: + return 8 + case DeviceAuraONELimitedEdition, DeviceForma32: + return 32 + } + panic("unknown device") } diff --git a/kobo/device_test.go b/kobo/device_test.go index 38b0e6c..9ece5fb 100644 --- a/kobo/device_test.go +++ b/kobo/device_test.go @@ -1,14 +1,105 @@ package kobo -import "testing" +import ( + "fmt" + "image" + "reflect" + "testing" +) -func TestDeviceByID(t *testing.T) { - for _, d := range Devices { - if dd, _ := DeviceByID(d.ID); dd.ID != d.ID || dd.Hardware != d.Hardware || dd.Name != d.Name { - t.Errorf("id '%s should be device %v", d.ID, d) +func TestDeviceList(t *testing.T) { + // check this manually (automatically doing this would just be a duplicate of tbe info) + for _, d := range Devices() { + fmt.Printf("Device %d (%s):\n Family: %s (%s)\n Hardware: %s\n IDString: %s\n Storage: %dGB\n CodeNames: %s\n Cover Types:\n", int(d), d.Name(), d.Family(), d.CodeNames().Family(), d.Hardware(), d.IDString(), d.StorageGB(), d.CodeNames()) + for _, c := range CoverTypes() { + fmt.Printf(" %s: %s\n", c, d.CoverSize(c)) } + fmt.Println() } - if d, ok := DeviceByID("asdasd"); ok || d != nil { + if d, ok := DeviceByID("asdasd"); ok || d != 0 { t.Errorf("id 'asdasd' should not return a device") } } + +func TestCoverGeneratePath(t *testing.T) { + for _, tc := range []struct { + ct CoverType + ext bool + iid string + out string + }{ + // note: the image ids and content ids are already tested, no need to do that here; just test the hashing and file path + { + CoverTypeFull, false, + "file____mnt_onboard_kepubify_Books_converted_Patrick_Gaskin_Test_Book_1_kepub_epub", + ".kobo-images/210/143/file____mnt_onboard_kepubify_Books_converted_Patrick_Gaskin_Test_Book_1_kepub_epub - N3_FULL.parsed", + }, + { + CoverTypeLibFull, false, + "file____mnt_onboard_kepubify_Books_converted_Patrick_Gaskin_Test_Book_1_kepub_epub", + ".kobo-images/210/143/file____mnt_onboard_kepubify_Books_converted_Patrick_Gaskin_Test_Book_1_kepub_epub - N3_LIBRARY_FULL.parsed", + }, + { + CoverTypeLibGrid, false, + "file____mnt_onboard_kepubify_Books_converted_Patrick_Gaskin_Test_Book_1_kepub_epub", + ".kobo-images/210/143/file____mnt_onboard_kepubify_Books_converted_Patrick_Gaskin_Test_Book_1_kepub_epub - N3_LIBRARY_GRID.parsed", + }, + { + CoverTypeLibList, false, + "file____mnt_onboard_kepubify_Books_converted_Patrick_Gaskin_Test_Book_1_kepub_epub", + ".kobo-images/210/143/file____mnt_onboard_kepubify_Books_converted_Patrick_Gaskin_Test_Book_1_kepub_epub - N3_LIBRARY_LIST.parsed", + }, + { + CoverTypeLibList, true, + "file____mnt_onboard_kepubify_Books_converted_Patrick_Gaskin_Test_Book_2__kepub_epub", + "koboExtStorage/images-cache/82/246/file____mnt_onboard_kepubify_Books_converted_Patrick_Gaskin_Test_Book_2__kepub_epub - N3_LIBRARY_LIST.parsed", + }, + } { + if path := tc.ct.GeneratePath(tc.ext, tc.iid); path != tc.out { + t.Errorf("(%s, ext: %t, iid: %#v): expected %#v, got %#v", tc.ct, tc.ext, tc.iid, tc.out, path) + } + } +} + +func TestSwitchCases(t *testing.T) { + for _, d := range Devices() { + for _, fn := range []interface{}{ + d.CodeNames, + d.Family, + d.Hardware, + d.ID, + d.IDString, + d.Name, + d.StorageGB, + d.String, + } { + if panics(fn) { + t.Errorf("%s: %s panics", d, reflect.ValueOf(fn)) + } + } + for _, ct := range CoverTypes() { + if panics(func() image.Point { return d.CoverSize(ct) }) { + t.Errorf("%s: CoverSize panics for %s", d, ct) + } + } + } +} + +func panics(fn interface{}) (panicked bool) { + v := reflect.ValueOf(fn) + if t := v.Type(); t.Kind() != reflect.Func { + panic("not a func") + } else if t.NumIn() != 0 { + panic("func requires args") + } + + defer func() { + if err := recover(); err != nil { + fmt.Println(err) + panicked = true + } + }() + v.Call(nil) + + return false +} diff --git a/kobo/util.go b/kobo/util.go index 0712e7d..850bb74 100644 --- a/kobo/util.go +++ b/kobo/util.go @@ -1,10 +1,67 @@ package kobo import ( + "fmt" + "image" + "path/filepath" "strconv" "strings" ) +// PathToContentID generates the Kobo ContentId for a path relative to the +// internal storage root (slashes are converted to forward slashes automatically). +func PathToContentID(relpath string) string { + return fmt.Sprintf("file:///mnt/onboard/%s", filepath.ToSlash(relpath)) +} + +// ContentIDToImageID converts the Kobo ContentId to the ImageId. +func ContentIDToImageID(contentID string) string { + return strings.NewReplacer( + " ", "_", + "/", "_", + ":", "_", + ".", "_", + ).Replace(contentID) +} + +// resizeKeepAspectRatio resizes sz to fill bounds while keeping the aspect +// ratio. It is based on the code for QSize::scaled with the modes +// Qt::KeepAspectRatio and Qt::KeepAspectRatioByExpanding. +func resizeKeepAspectRatio(sz image.Point, bounds image.Point, expand bool) image.Point { + if sz.X == 0 || sz.Y == 0 { + return sz + } + + var useHeight bool + ar := float64(sz.X) / float64(sz.Y) + rw := int(float64(bounds.Y) * ar) + + if !expand { + useHeight = rw <= bounds.X + } else { + useHeight = rw >= bounds.X + } + + if useHeight { + return image.Pt(rw, bounds.Y) + } + return image.Pt(bounds.X, int(float64(bounds.X)/ar)) +} + +// hashedImageParts returns the parts needed for constructing the path to the +// cached image. The result can be applied like: +// .kobo-images/{dir1}/{dir2}/{basename} - N3_SOMETHING.parsed +func hashedImageParts(imageID string) (dir1, dir2, basename string) { + imgID := []byte(imageID) + h := uint32(0x00000000) + for _, x := range imgID { + h = (h << 4) + uint32(x) + h ^= (h & 0xf0000000) >> 23 + h &= 0x0fffffff + } + return fmt.Sprintf("%d", h&(0xff*1)), fmt.Sprintf("%d", (h&(0xff00*1))>>8), imageID +} + func strSplitInt(str string) []int64 { spl := strings.Split(str, ".") ints := make([]int64, len(spl)) diff --git a/kobo/util_test.go b/kobo/util_test.go new file mode 100644 index 0000000..10ac802 --- /dev/null +++ b/kobo/util_test.go @@ -0,0 +1,107 @@ +package kobo + +import ( + "image" + "path/filepath" + "testing" +) + +func TestPathContentIDImageID(t *testing.T) { + for _, tc := range []struct { + path string + cid string + iid string + }{ + { + "kepubify/Books_converted/Patrick Gaskin/Test Book 1.kepub.epub", + "file:///mnt/onboard/kepubify/Books_converted/Patrick Gaskin/Test Book 1.kepub.epub", + "file____mnt_onboard_kepubify_Books_converted_Patrick_Gaskin_Test_Book_1_kepub_epub", + }, + { + "kepubify/Books_converted/Patrick Gaskin/Test Book 2:.kepub.epub", + "file:///mnt/onboard/kepubify/Books_converted/Patrick Gaskin/Test Book 2:.kepub.epub", + "file____mnt_onboard_kepubify_Books_converted_Patrick_Gaskin_Test_Book_2__kepub_epub", + }, + } { + cid := PathToContentID(tc.path) + iid := ContentIDToImageID(tc.cid) + + if cid != PathToContentID(filepath.FromSlash(tc.path)) { + t.Errorf("incorrect native path separator conversion") + } + + if tc.cid != cid { + t.Errorf("cid of %#v: expected %#v, got %#v", tc.path, tc.cid, cid) + } + + if tc.iid != iid { + t.Errorf("iid of %#v: expected %#v, got %#v", tc.cid, tc.iid, iid) + } + } +} + +func TestResizeKeepAspectRatioExpand(t *testing.T) { + for _, tc := range []struct { + sz image.Point + bounds image.Point + rsz image.Point + }{ + // don't resize if width or height is zero + {image.Pt(0, 0), image.Pt(0, 0), image.Pt(0, 0)}, + {image.Pt(1, 0), image.Pt(0, 0), image.Pt(1, 0)}, + {image.Pt(0, 1), image.Pt(0, 0), image.Pt(0, 1)}, + // same aspect ratio + {image.Pt(1, 1), image.Pt(1, 1), image.Pt(1, 1)}, + {image.Pt(1, 1), image.Pt(5, 5), image.Pt(5, 5)}, + {image.Pt(5, 5), image.Pt(1, 1), image.Pt(1, 1)}, + // limited by width + {image.Pt(2, 3), image.Pt(6, 6), image.Pt(6, 9)}, + {image.Pt(2, 4), image.Pt(6, 6), image.Pt(6, 12)}, + {image.Pt(6, 9), image.Pt(2, 3), image.Pt(2, 3)}, + {image.Pt(6, 12), image.Pt(2, 4), image.Pt(2, 4)}, + // limited by height + {image.Pt(3, 2), image.Pt(6, 6), image.Pt(9, 6)}, + {image.Pt(4, 2), image.Pt(6, 6), image.Pt(12, 6)}, + {image.Pt(9, 6), image.Pt(3, 2), image.Pt(3, 2)}, + {image.Pt(12, 6), image.Pt(4, 2), image.Pt(4, 2)}, + // fractional stuff + {image.Pt(1391, 2200), image.Pt(355, 530), image.Pt(355, 561)}, + } { + if tsz := resizeKeepAspectRatio(tc.sz, tc.bounds, true); !tsz.Eq(tc.rsz) { + t.Errorf("(%s, %s, %t): expected %s, got %s", tc.sz, tc.bounds, true, tc.rsz, tsz) + } + } +} + +func TestResizeKeepAspectRatioShrink(t *testing.T) { + for _, tc := range []struct { + sz image.Point + bounds image.Point + rsz image.Point + }{ + // don't resize if width or height is zero + {image.Pt(0, 0), image.Pt(0, 0), image.Pt(0, 0)}, + {image.Pt(1, 0), image.Pt(0, 0), image.Pt(1, 0)}, + {image.Pt(0, 1), image.Pt(0, 0), image.Pt(0, 1)}, + // same aspect ratio + {image.Pt(1, 1), image.Pt(1, 1), image.Pt(1, 1)}, + {image.Pt(1, 1), image.Pt(5, 5), image.Pt(5, 5)}, + {image.Pt(5, 5), image.Pt(1, 1), image.Pt(1, 1)}, + // limited by width + {image.Pt(2, 3), image.Pt(6, 6), image.Pt(4, 6)}, + {image.Pt(2, 4), image.Pt(6, 6), image.Pt(3, 6)}, + {image.Pt(6, 9), image.Pt(2, 3), image.Pt(2, 3)}, + {image.Pt(6, 12), image.Pt(2, 4), image.Pt(2, 4)}, + // limited by height + {image.Pt(3, 2), image.Pt(6, 6), image.Pt(6, 4)}, + {image.Pt(4, 2), image.Pt(6, 6), image.Pt(6, 3)}, + {image.Pt(9, 6), image.Pt(3, 2), image.Pt(3, 2)}, + {image.Pt(12, 6), image.Pt(4, 2), image.Pt(4, 2)}, + // fractional stuff + {image.Pt(1391, 2200), image.Pt(355, 530), image.Pt(335, 530)}, + } { + if tsz := resizeKeepAspectRatio(tc.sz, tc.bounds, false); !tsz.Eq(tc.rsz) { + t.Errorf("(%s, %s, %t): expected %s, got %s", tc.sz, tc.bounds, false, tc.rsz, tsz) + } + } +} diff --git a/kobo/version_test.go b/kobo/version_test.go index ae4793b..228480b 100644 --- a/kobo/version_test.go +++ b/kobo/version_test.go @@ -53,6 +53,32 @@ func TestParseKoboVersion(t *testing.T) { } } +func TestParseKoboAffiliate(t *testing.T) { + if err := fakekobo(func(kpath string) { + aff, err := ParseKoboAffiliate(kpath) + if err != nil { + t.Error(err) + } else if aff != "Kobo" { + t.Errorf("expected Kobo, got %#v", aff) + } + }); err != nil { + t.Fatal(err) + } +} + +func TestIsKobo(t *testing.T) { + if err := fakekobo(func(kpath string) { + if !IsKobo(kpath) { + t.Errorf("expected fake kobo to be a kobo") + } + }); err != nil { + t.Fatal(err) + } + if IsKobo(".") { + t.Errorf("expected current dir not to be a kobo") + } +} + func fakekobo(fn func(kpath string)) error { td, err := ioutil.TempDir("", "koboutils") if err != nil { @@ -70,6 +96,11 @@ func fakekobo(fn func(kpath string)) error { return fmt.Errorf("could not write fake version file: %v", err) } + err = ioutil.WriteFile(filepath.Join(td, ".kobo", "affiliate.conf"), []byte("[General]\naffiliate=Kobo"), 0644) + if err != nil { + return fmt.Errorf("could not write fake affiliate file: %v", err) + } + fn(td) return nil