diff --git a/Makefile b/Makefile index 21ab24c..190657b 100644 --- a/Makefile +++ b/Makefile @@ -25,3 +25,10 @@ docker_build_release_latest: -t gitea.va.reichard.io/evan/bookmanager:latest \ -t gitea.va.reichard.io/evan/bookmanager:`git describe --tags` \ --push . + +tests_integration: + go test -v -tags=integration -coverpkg=./... ./metadata + + +tests_unit: + SET_TEST=set_val go test -v -coverpkg=./... ./... diff --git a/api/api.go b/api/api.go index b93f884..e43e2f9 100644 --- a/api/api.go +++ b/api/api.go @@ -13,8 +13,6 @@ import ( log "github.com/sirupsen/logrus" "reichard.io/bbank/config" "reichard.io/bbank/database" - "reichard.io/bbank/graph" - "reichard.io/bbank/utils" ) type API struct { @@ -75,9 +73,9 @@ func (api *API) registerWebAppRoutes() { // Define Templates & Helper Functions render := multitemplate.NewRenderer() helperFuncs := template.FuncMap{ - "GetSVGGraphData": graph.GetSVGGraphData, - "GetUTCOffsets": utils.GetUTCOffsets, - "NiceSeconds": utils.NiceSeconds, + "GetSVGGraphData": getSVGGraphData, + "GetUTCOffsets": getUTCOffsets, + "NiceSeconds": niceSeconds, } render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html") diff --git a/api/app-routes.go b/api/app-routes.go index c22d875..7d346d5 100644 --- a/api/app-routes.go +++ b/api/app-routes.go @@ -19,7 +19,6 @@ import ( "reichard.io/bbank/database" "reichard.io/bbank/metadata" "reichard.io/bbank/search" - "reichard.io/bbank/utils" ) type queryParams struct { @@ -587,7 +586,7 @@ func (api *API) saveNewDocument(c *gin.Context) { } // Calculate Partial MD5 ID - partialMD5, err := utils.CalculatePartialMD5(tempFilePath) + partialMD5, err := calculatePartialMD5(tempFilePath) if err != nil { log.Warn("[saveNewDocument] Partial MD5 Error: ", err) c.AbortWithStatus(http.StatusBadRequest) diff --git a/utils/utils.go b/api/utils.go similarity index 84% rename from utils/utils.go rename to api/utils.go index 883ab84..273077e 100644 --- a/utils/utils.go +++ b/api/utils.go @@ -1,4 +1,4 @@ -package utils +package api import ( "bytes" @@ -7,6 +7,9 @@ import ( "io" "math" "os" + + "reichard.io/bbank/database" + "reichard.io/bbank/graph" ) type UTCOffset struct { @@ -55,11 +58,11 @@ var UTC_OFFSETS = []UTCOffset{ {Value: "+14 hours", Name: "UTC+14:00"}, } -func GetUTCOffsets() []UTCOffset { +func getUTCOffsets() []UTCOffset { return UTC_OFFSETS } -func NiceSeconds(input int64) (result string) { +func niceSeconds(input int64) (result string) { if input == 0 { return "N/A" } @@ -88,7 +91,7 @@ func NiceSeconds(input int64) (result string) { } // Reimplemented KOReader Partial MD5 Calculation -func CalculatePartialMD5(filePath string) (string, error) { +func calculatePartialMD5(filePath string) (string, error) { file, err := os.Open(filePath) if err != nil { return "", err @@ -121,3 +124,13 @@ func CalculatePartialMD5(filePath string) (string, error) { allBytes := buf.Bytes() return fmt.Sprintf("%x", md5.Sum(allBytes)), nil } + +// Convert Database Array -> Int64 Array +func getSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, svgHeight int) graph.SVGGraphData { + var intData []int64 + for _, item := range inputData { + intData = append(intData, item.MinutesRead) + } + + return graph.GetSVGGraphData(intData, svgWidth, svgHeight) +} diff --git a/config/config.go b/config/config.go index aeb0e9c..d94f7e7 100644 --- a/config/config.go +++ b/config/config.go @@ -13,7 +13,6 @@ type Config struct { // DB Configuration DBType string DBName string - DBPassword string // Data Paths ConfigPath string @@ -30,7 +29,6 @@ func Load() *Config { Version: "0.0.2", DBType: trimLowerString(getEnv("DATABASE_TYPE", "SQLite")), DBName: trimLowerString(getEnv("DATABASE_NAME", "book_manager")), - DBPassword: getEnv("DATABASE_PASSWORD", ""), ConfigPath: getEnv("CONFIG_PATH", "/config"), DataPath: getEnv("DATA_PATH", "/data"), ListenPort: getEnv("LISTEN_PORT", "8585"), diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..353c9e6 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,35 @@ +package config + +import "testing" + +func TestLoadConfig(t *testing.T) { + conf := Load() + want := "sqlite" + if conf.DBType != want { + t.Fatalf(`Load().DBType = %q, want match for %#q, nil`, conf.DBType, want) + } +} + +func TestGetEnvDefault(t *testing.T) { + want := "def_val" + envDefault := getEnv("DEFAULT_TEST", want) + if envDefault != want { + t.Fatalf(`getEnv("DEFAULT_TEST", "def_val") = %q, want match for %#q, nil`, envDefault, want) + } +} + +func TestGetEnvSet(t *testing.T) { + envDefault := getEnv("SET_TEST", "not_this") + want := "set_val" + if envDefault != want { + t.Fatalf(`getEnv("SET_TEST", "not_this") = %q, want match for %#q, nil`, envDefault, want) + } +} + +func TestTrimLowerString(t *testing.T) { + want := "trimtest" + output := trimLowerString(" trimTest ") + if output != want { + t.Fatalf(`trimLowerString(" trimTest ") = %q, want match for %#q, nil`, output, want) + } +} diff --git a/database/manager.go b/database/manager.go index 3b85a86..56afc75 100644 --- a/database/manager.go +++ b/database/manager.go @@ -6,14 +6,9 @@ import ( _ "embed" "fmt" log "github.com/sirupsen/logrus" + _ "modernc.org/sqlite" "path" "reichard.io/bbank/config" - - // CGO SQLite - // sqlite "github.com/mattn/go-sqlite3" - - // GO SQLite - _ "modernc.org/sqlite" ) type DBManager struct { @@ -35,9 +30,12 @@ func NewMgr(c *config.Config) *DBManager { } // Create Database - if c.DBType == "sqlite" { - // GO SQLite - dbLocation := path.Join(c.ConfigPath, fmt.Sprintf("%s.db", c.DBName)) + if c.DBType == "sqlite" || c.DBType == "memory" { + var dbLocation string = ":memory:" + if c.DBType == "sqlite" { + dbLocation = path.Join(c.ConfigPath, fmt.Sprintf("%s.db", c.DBName)) + } + var err error dbm.DB, err = sql.Open("sqlite", dbLocation) if err != nil { @@ -49,19 +47,6 @@ func NewMgr(c *config.Config) *DBManager { if _, err := dbm.DB.Exec(ddl, nil); err != nil { log.Info("Exec Error:", err) } - - // CGO SQLite - // sql.Register("sqlite3_custom", &sqlite.SQLiteDriver{ - // ConnectHook: connectHookSQLite, - // }) - - // dbLocation := path.Join(c.ConfigPath, fmt.Sprintf("%s.db", c.DBName)) - - // var err error - // dbm.DB, err = sql.Open("sqlite3_custom", dbLocation) - // if err != nil { - // log.Fatal(err) - // } } else { log.Fatal("Unsupported Database") } @@ -77,15 +62,3 @@ func (dbm *DBManager) CacheTempTables() error { } return nil } - -/* -// CGO SQLite -func connectHookSQLite(conn *sqlite.SQLiteConn) error { - // Create Tables - log.Debug("Creating Schema") - if _, err := conn.Exec(ddl, nil); err != nil { - log.Warn("Create Schema Failure: ", err) - } - return nil -} -*/ diff --git a/database/manager_test.go b/database/manager_test.go new file mode 100644 index 0000000..abf930c --- /dev/null +++ b/database/manager_test.go @@ -0,0 +1,213 @@ +package database + +import ( + "testing" + "time" + + "reichard.io/bbank/config" +) + +type databaseTest struct { + *testing.T + dbm *DBManager +} + +var userID string = "testUser" +var userPass string = "testPass" +var deviceID string = "testDevice" +var deviceName string = "testDeviceName" +var documentID string = "testDocument" +var documentTitle string = "testTitle" +var documentAuthor string = "testAuthor" + +func TestNewMgr(t *testing.T) { + cfg := config.Config{ + DBType: "memory", + } + + dbm := NewMgr(&cfg) + if dbm == nil { + t.Fatalf(`Expected: *DBManager, Got: nil`) + } + + t.Run("Database", func(t *testing.T) { + dt := databaseTest{t, dbm} + dt.TestUser() + dt.TestDocument() + dt.TestDevice() + dt.TestActivity() + dt.TestDailyReadStats() + }) +} + +func (dt *databaseTest) TestUser() { + dt.Run("User", func(t *testing.T) { + changed, err := dt.dbm.Queries.CreateUser(dt.dbm.Ctx, CreateUserParams{ + ID: userID, + Pass: &userPass, + }) + + if err != nil || changed != 1 { + t.Fatalf(`Expected: %v, Got: %v, Error: %v`, 1, changed, err) + } + + user, err := dt.dbm.Queries.GetUser(dt.dbm.Ctx, userID) + if err != nil || *user.Pass != userPass { + t.Fatalf(`Expected: %v, Got: %v, Error: %v`, userPass, *user.Pass, err) + } + }) +} + +func (dt *databaseTest) TestDocument() { + dt.Run("Document", func(t *testing.T) { + doc, err := dt.dbm.Queries.UpsertDocument(dt.dbm.Ctx, UpsertDocumentParams{ + ID: documentID, + Title: &documentTitle, + Author: &documentAuthor, + }) + + if err != nil { + t.Fatalf(`Expected: Document, Got: %v, Error: %v`, doc, err) + } + + if doc.ID != documentID { + t.Fatalf(`Expected: %v, Got: %v`, documentID, doc.ID) + } + + if *doc.Title != documentTitle { + t.Fatalf(`Expected: %v, Got: %v`, documentTitle, *doc.Title) + } + + if *doc.Author != documentAuthor { + t.Fatalf(`Expected: %v, Got: %v`, documentAuthor, *doc.Author) + } + }) +} + +func (dt *databaseTest) TestDevice() { + dt.Run("Device", func(t *testing.T) { + device, err := dt.dbm.Queries.UpsertDevice(dt.dbm.Ctx, UpsertDeviceParams{ + ID: deviceID, + UserID: userID, + DeviceName: deviceName, + }) + + if err != nil { + t.Fatalf(`Expected: Device, Got: %v, Error: %v`, device, err) + } + + if device.ID != deviceID { + t.Fatalf(`Expected: %v, Got: %v`, deviceID, device.ID) + } + + if device.UserID != userID { + t.Fatalf(`Expected: %v, Got: %v`, userID, device.UserID) + } + + if device.DeviceName != deviceName { + t.Fatalf(`Expected: %v, Got: %v`, deviceName, device.DeviceName) + } + }) +} + +func (dt *databaseTest) TestActivity() { + dt.Run("Progress", func(t *testing.T) { + // 10 Activities, 10 Days + end := time.Now() + start := end.AddDate(0, 0, -9) + var counter int64 = 0 + + for d := start; d.After(end) == false; d = d.AddDate(0, 0, 1) { + counter += 1 + + // Add Item + activity, err := dt.dbm.Queries.AddActivity(dt.dbm.Ctx, AddActivityParams{ + DocumentID: documentID, + DeviceID: deviceID, + UserID: userID, + StartTime: d.UTC().Format(time.RFC3339), + Duration: 60, + Page: counter, + Pages: 100, + }) + + // Validate No Error + if err != nil { + t.Fatalf(`expected: rawactivity, got: %v, error: %v`, activity, err) + } + + // Validate Auto Increment Working + if activity.ID != counter { + t.Fatalf(`Expected: %v, Got: %v`, counter, activity.ID) + } + } + + // Initiate Cache + if err := dt.dbm.CacheTempTables(); err != nil { + t.Fatalf(`Error: %v`, err) + } + + // Validate Exists + existsRows, err := dt.dbm.Queries.GetActivity(dt.dbm.Ctx, GetActivityParams{ + UserID: userID, + Offset: 0, + Limit: 50, + }) + + if err != nil { + t.Fatalf(`Expected: []GetActivityRow, Got: %v, Error: %v`, existsRows, err) + } + + if len(existsRows) != 10 { + t.Fatalf(`Expected: %v, Got: %v`, 10, len(existsRows)) + } + + // Validate Doesn't Exist + doesntExistsRows, err := dt.dbm.Queries.GetActivity(dt.dbm.Ctx, GetActivityParams{ + UserID: userID, + DocumentID: "unknownDoc", + DocFilter: true, + Offset: 0, + Limit: 50, + }) + + if err != nil { + t.Fatalf(`Expected: []GetActivityRow, Got: %v, Error: %v`, doesntExistsRows, err) + } + + if len(doesntExistsRows) != 0 { + t.Fatalf(`Expected: %v, Got: %v`, 0, len(doesntExistsRows)) + } + }) +} + +func (dt *databaseTest) TestDailyReadStats() { + dt.Run("DailyReadStats", func(t *testing.T) { + readStats, err := dt.dbm.Queries.GetDailyReadStats(dt.dbm.Ctx, userID) + + if err != nil { + t.Fatalf(`Expected: []GetDailyReadStatsRow, Got: %v, Error: %v`, readStats, err) + } + + // Validate 30 Days Stats + if len(readStats) != 30 { + t.Fatalf(`Expected: %v, Got: %v`, 30, len(readStats)) + } + + // Validate 1 Minute / Day - Last 10 Days + for i := 0; i < 10; i++ { + stat := readStats[i] + if stat.MinutesRead != 1 { + t.Fatalf(`Day: %v, Expected: %v, Got: %v`, stat.Date, 1, stat.MinutesRead) + } + } + + // Validate 0 Minute / Day - Remaining 20 Days + for i := 10; i < 30; i++ { + stat := readStats[i] + if stat.MinutesRead != 0 { + t.Fatalf(`Day: %v, Expected: %v, Got: %v`, stat.Date, 0, stat.MinutesRead) + } + } + }) +} diff --git a/go.mod b/go.mod index 570a813..363006f 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module reichard.io/bbank go 1.19 require ( + github.com/PuerkitoBio/goquery v1.8.1 github.com/alexedwards/argon2id v0.0.0-20230305115115-4b3c3280a736 github.com/gabriel-vasile/mimetype v1.4.2 github.com/gin-contrib/multitemplate v0.0.0-20230212012517-45920c92c271 @@ -10,6 +11,7 @@ require ( github.com/gin-gonic/gin v1.9.1 github.com/microcosm-cc/bluemonday v1.0.25 github.com/sirupsen/logrus v1.9.3 + github.com/taylorskalyo/goreader v0.0.0-20230626212555-e7f5644f8115 github.com/urfave/cli/v2 v2.25.7 golang.org/x/exp v0.0.0-20230905200255-921286631fa9 golang.org/x/net v0.15.0 @@ -17,7 +19,6 @@ require ( ) require ( - github.com/PuerkitoBio/goquery v1.8.1 // indirect github.com/andybalholm/cascadia v1.3.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/bytedance/sonic v1.10.0 // indirect diff --git a/go.sum b/go.sum index c406e60..24d2aea 100644 --- a/go.sum +++ b/go.sum @@ -146,6 +146,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/taylorskalyo/goreader v0.0.0-20230626212555-e7f5644f8115 h1:OEAIMYp5l9kJ2kT9UPL5QSUriKIIDhnLmpJTy69sltA= +github.com/taylorskalyo/goreader v0.0.0-20230626212555-e7f5644f8115/go.mod h1:AIVbkIe1G7fpFHiKOdxZnU5p9tFPYNTQyH3H5IrRkGw= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= @@ -225,7 +227,6 @@ golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= diff --git a/graph/graph.go b/graph/graph.go index a22dd46..a373e45 100644 --- a/graph/graph.go +++ b/graph/graph.go @@ -3,8 +3,6 @@ package graph import ( "fmt" "math" - - "reichard.io/bbank/database" ) type SVGGraphPoint struct { @@ -28,12 +26,12 @@ type SVGBezierOpposedLine struct { Angle int } -func GetSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, svgHeight int) SVGGraphData { +func GetSVGGraphData(inputData []int64, svgWidth int, svgHeight int) SVGGraphData { // Derive Height var maxHeight int = 0 for _, item := range inputData { - if int(item.MinutesRead) > maxHeight { - maxHeight = int(item.MinutesRead) + if int(item) > maxHeight { + maxHeight = int(item) } } @@ -55,7 +53,7 @@ func GetSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, sv var maxBY int = 0 var minBX int = 0 for idx, item := range inputData { - itemSize := int(float32(item.MinutesRead) * sizeRatio) + itemSize := int(float32(item) * sizeRatio) itemY := svgHeight - itemSize lineX := (idx + 1) * blockOffset barPoints = append(barPoints, SVGGraphPoint{ diff --git a/graph/graph_test.go b/graph/graph_test.go new file mode 100644 index 0000000..23298e4 --- /dev/null +++ b/graph/graph_test.go @@ -0,0 +1,33 @@ +package graph + +import ( + "testing" +) + +func TestGetSVGGraphData(t *testing.T) { + inputPoints := []int64{10, 90, 50, 5, 10, 5, 70, 60, 50, 90} + svgData := GetSVGGraphData(inputPoints, 500, 100) + + expect := "M 50,95 C63,95 80,50 100,50 C120,50 128,73 150,73 C172,73 180,98 200,98 C220,98 230,95 250,95 C270,95 279,98 300,98 C321,98 330,62 350,62 C370,62 380,67 400,67 C420,67 430,73 450,73 C470,73 489,50 500,50" + if svgData.BezierPath != expect { + t.Fatalf(`Expected: %v, Got: %v`, expect, svgData.BezierPath) + } + + expect = "L 500,98 L 50,98 Z" + if svgData.BezierFill != expect { + t.Fatalf(`Expected: %v, Got: %v`, expect, svgData.BezierFill) + } + + if svgData.Width != 500 { + t.Fatalf(`Expected: %v, Got: %v`, 500, svgData.Width) + } + + if svgData.Height != 100 { + t.Fatalf(`Expected: %v, Got: %v`, 100, svgData.Height) + } + + if svgData.Offset != 50 { + t.Fatalf(`Expected: %v, Got: %v`, 50, svgData.Offset) + } + +} diff --git a/metadata/_test_files/alice.epub b/metadata/_test_files/alice.epub new file mode 100644 index 0000000..73467c2 Binary files /dev/null and b/metadata/_test_files/alice.epub differ diff --git a/metadata/epub.go b/metadata/epub.go index 7d8261b..9847d9d 100644 --- a/metadata/epub.go +++ b/metadata/epub.go @@ -1,301 +1,35 @@ -/* -Package epub provides basic support for reading EPUB archives. -Adapted from: https://github.com/taylorskalyo/goreader -*/ package metadata import ( - "archive/zip" - "bytes" - "encoding/xml" - "errors" "io" - "os" - "path" "strings" + "github.com/taylorskalyo/goreader/epub" "golang.org/x/net/html" ) -const containerPath = "META-INF/container.xml" - -var ( - // ErrNoRootfile occurs when there are no rootfile entries found in - // container.xml. - ErrNoRootfile = errors.New("epub: no rootfile found in container") - - // ErrBadRootfile occurs when container.xml references a rootfile that does - // not exist in the zip. - ErrBadRootfile = errors.New("epub: container references non-existent rootfile") - - // ErrNoItemref occurrs when a content.opf contains a spine without any - // itemref entries. - ErrNoItemref = errors.New("epub: no itemrefs found in spine") - - // ErrBadItemref occurs when an itemref entry in content.opf references an - // item that does not exist in the manifest. - ErrBadItemref = errors.New("epub: itemref references non-existent item") - - // ErrBadManifest occurs when a manifest in content.opf references an item - // that does not exist in the zip. - ErrBadManifest = errors.New("epub: manifest references non-existent item") -) - -// Reader represents a readable epub file. -type Reader struct { - Container - files map[string]*zip.File -} - -// ReadCloser represents a readable epub file that can be closed. -type ReadCloser struct { - Reader - f *os.File -} - -// Rootfile contains the location of a content.opf package file. -type Rootfile struct { - FullPath string `xml:"full-path,attr"` - Package -} - -// Container serves as a directory of Rootfiles. -type Container struct { - Rootfiles []*Rootfile `xml:"rootfiles>rootfile"` -} - -// Package represents an epub content.opf file. -type Package struct { - Metadata - Manifest - Spine -} - -// Metadata contains publishing information about the epub. -type Metadata struct { - Title string `xml:"metadata>title"` - Language string `xml:"metadata>language"` - Identifier string `xml:"metadata>idenifier"` - Creator string `xml:"metadata>creator"` - Contributor string `xml:"metadata>contributor"` - Publisher string `xml:"metadata>publisher"` - Subject string `xml:"metadata>subject"` - Description string `xml:"metadata>description"` - Event []struct { - Name string `xml:"event,attr"` - Date string `xml:",innerxml"` - } `xml:"metadata>date"` - Type string `xml:"metadata>type"` - Format string `xml:"metadata>format"` - Source string `xml:"metadata>source"` - Relation string `xml:"metadata>relation"` - Coverage string `xml:"metadata>coverage"` - Rights string `xml:"metadata>rights"` -} - -// Manifest lists every file that is part of the epub. -type Manifest struct { - Items []Item `xml:"manifest>item"` -} - -// Item represents a file stored in the epub. -type Item struct { - ID string `xml:"id,attr"` - HREF string `xml:"href,attr"` - MediaType string `xml:"media-type,attr"` - f *zip.File -} - -// Spine defines the reading order of the epub documents. -type Spine struct { - Itemrefs []Itemref `xml:"spine>itemref"` -} - -// Itemref points to an Item. -type Itemref struct { - IDREF string `xml:"idref,attr"` - *Item -} - -// OpenEPUBReader will open the epub file specified by name and return a -// ReadCloser. -func OpenEPUBReader(name string) (*ReadCloser, error) { - f, err := os.Open(name) +func countEPUBWords(filepath string) (int64, error) { + rc, err := epub.OpenReader(filepath) if err != nil { - return nil, err + return 0, err } + rf := rc.Rootfiles[0] - rc := new(ReadCloser) - rc.f = f - - fi, err := f.Stat() - if err != nil { - f.Close() - return nil, err - } - - z, err := zip.NewReader(f, fi.Size()) - if err != nil { - return nil, err - } - - if err = rc.init(z); err != nil { - return nil, err - } - - return rc, nil -} - -// NewReader returns a new Reader reading from ra, which is assumed to have the -// given size in bytes. -func NewReader(ra io.ReaderAt, size int64) (*Reader, error) { - z, err := zip.NewReader(ra, size) - if err != nil { - return nil, err - } - - r := new(Reader) - if err = r.init(z); err != nil { - return nil, err - } - - return r, nil -} - -func (r *Reader) init(z *zip.Reader) error { - // Create a file lookup table - r.files = make(map[string]*zip.File) - for _, f := range z.File { - r.files[f.Name] = f - } - - err := r.setContainer() - if err != nil { - return err - } - err = r.setPackages() - if err != nil { - return err - } - err = r.setItems() - if err != nil { - return err - } - - return nil -} - -// setContainer unmarshals the epub's container.xml file. -func (r *Reader) setContainer() error { - f, err := r.files[containerPath].Open() - if err != nil { - return err - } - - var b bytes.Buffer - _, err = io.Copy(&b, f) - if err != nil { - return err - } - - err = xml.Unmarshal(b.Bytes(), &r.Container) - if err != nil { - return err - } - - if len(r.Container.Rootfiles) < 1 { - return ErrNoRootfile - } - - return nil -} - -// setPackages unmarshal's each of the epub's content.opf files. -func (r *Reader) setPackages() error { - for _, rf := range r.Container.Rootfiles { - if r.files[rf.FullPath] == nil { - return ErrBadRootfile - } - - f, err := r.files[rf.FullPath].Open() - if err != nil { - return err - } - - var b bytes.Buffer - _, err = io.Copy(&b, f) - if err != nil { - return err - } - - err = xml.Unmarshal(b.Bytes(), &rf.Package) - if err != nil { - return err - } - } - - return nil -} - -// setItems associates Itemrefs with their respective Item and Items with -// their zip.File. -func (r *Reader) setItems() error { - itemrefCount := 0 - for _, rf := range r.Container.Rootfiles { - itemMap := make(map[string]*Item) - for i := range rf.Manifest.Items { - item := &rf.Manifest.Items[i] - itemMap[item.ID] = item - - abs := path.Join(path.Dir(rf.FullPath), item.HREF) - item.f = r.files[abs] - } - - for i := range rf.Spine.Itemrefs { - itemref := &rf.Spine.Itemrefs[i] - itemref.Item = itemMap[itemref.IDREF] - if itemref.Item == nil { - return ErrBadItemref - } - } - itemrefCount += len(rf.Spine.Itemrefs) - } - - if itemrefCount < 1 { - return ErrNoItemref - } - - return nil -} - -// Open returns a ReadCloser that provides access to the Items's contents. -// Multiple items may be read concurrently. -func (item *Item) Open() (r io.ReadCloser, err error) { - if item.f == nil { - return nil, ErrBadManifest - } - - return item.f.Open() -} - -// Close closes the epub file, rendering it unusable for I/O. -func (rc *ReadCloser) Close() { - rc.f.Close() -} - -// Hehe -func (rf *Rootfile) CountWords() int64 { var completeCount int64 for _, item := range rf.Spine.Itemrefs { f, _ := item.Open() tokenizer := html.NewTokenizer(f) - completeCount = completeCount + countWords(*tokenizer) + newCount, err := countTokenizerWords(*tokenizer) + if err != nil { + return 0, err + } + completeCount = completeCount + newCount } - return completeCount + return completeCount, nil } -func countWords(tokenizer html.Tokenizer) int64 { +func countTokenizerWords(tokenizer html.Tokenizer) (int64, error) { var err error var totalWords int64 for { @@ -308,23 +42,9 @@ func countWords(tokenizer html.Tokenizer) int64 { err = tokenizer.Err() } if err == io.EOF { - return totalWords + return totalWords, nil } else if err != nil { - return 0 + return 0, err } } } - -/* -func main() { - rc, err := OpenEPUBReader("test.epub") - if err != nil { - log.Fatal(err) - } - - rf := rc.Rootfiles[0] - - totalWords := rf.CountWords() - log.Info("WOAH WORDS:", totalWords) -} -*/ diff --git a/metadata/integrations_test.go b/metadata/integrations_test.go new file mode 100644 index 0000000..cd4a40a --- /dev/null +++ b/metadata/integrations_test.go @@ -0,0 +1,76 @@ +//go:build integration + +package metadata + +import ( + "testing" +) + +func TestGBooksGBIDMetadata(t *testing.T) { + GBID := "ZxwpakTv_MIC" + metadataResp, err := getGBooksMetadata(MetadataInfo{ + ID: &GBID, + }) + + if len(metadataResp) != 1 { + t.Fatalf(`Expected: %v, Got: %v, Error: %v`, 1, len(metadataResp), err) + } + + mResult := metadataResp[0] + validateResult(&mResult, t) +} + +func TestGBooksISBNQuery(t *testing.T) { + ISBN10 := "1877527815" + metadataResp, err := getGBooksMetadata(MetadataInfo{ + ISBN10: &ISBN10, + }) + + if len(metadataResp) != 1 { + t.Fatalf(`Expected: %v, Got: %v, Error: %v`, 1, len(metadataResp), err) + } + + mResult := metadataResp[0] + validateResult(&mResult, t) +} + +func TestGBooksTitleQuery(t *testing.T) { + title := "Alice in Wonderland" + metadataResp, err := getGBooksMetadata(MetadataInfo{ + Title: &title, + }) + + if len(metadataResp) == 0 { + t.Fatalf(`Expected: %v, Got: %v, Error: %v`, "> 0", len(metadataResp), err) + } + + mResult := metadataResp[0] + validateResult(&mResult, t) +} + +func validateResult(m *MetadataInfo, t *testing.T) { + expect := "Lewis Carroll" + if *m.Author != expect { + t.Fatalf(`Expected: %v, Got: %v`, expect, *m.Author) + } + + expect = "Alice in Wonderland" + if *m.Title != expect { + t.Fatalf(`Expected: %v, Got: %v`, expect, *m.Title) + } + + expect = "Alice in Wonderland (also known as Alice's Adventures in Wonderland), from 1865, is the peculiar and imaginative tale of a girl who falls down a rabbit-hole into a bizarre world of eccentric and unusual creatures. Lewis Carroll's prominent example of the genre of \"literary nonsense\" has endured in popularity with its clever way of playing with logic and a narrative structure that has influence generations of fiction writing." + if *m.Description != expect { + t.Fatalf(`Expected: %v, Got: %v`, expect, *m.Description) + } + + expect = "1877527815" + if *m.ISBN10 != expect { + t.Fatalf(`Expected: %v, Got: %v`, expect, *m.ISBN10) + } + + expect = "9781877527814" + if *m.ISBN13 != expect { + t.Fatalf(`Expected: %v, Got: %v`, expect, *m.ISBN13) + } +} diff --git a/metadata/metadata.go b/metadata/metadata.go index c3df694..259122c 100644 --- a/metadata/metadata.go +++ b/metadata/metadata.go @@ -58,13 +58,10 @@ func GetWordCount(filepath string) (int64, error) { } if fileExtension := fileMime.Extension(); fileExtension == ".epub" { - rc, err := OpenEPUBReader(filepath) + totalWords, err := countEPUBWords(filepath) if err != nil { return 0, err } - - rf := rc.Rootfiles[0] - totalWords := rf.CountWords() return totalWords, nil } else { return 0, errors.New("Invalid Extension") diff --git a/metadata/metadata_test.go b/metadata/metadata_test.go new file mode 100644 index 0000000..188d971 --- /dev/null +++ b/metadata/metadata_test.go @@ -0,0 +1,14 @@ +package metadata + +import ( + "testing" +) + +func TestGetWordCount(t *testing.T) { + var want int64 = 30477 + wordCount, err := countEPUBWords("./_test_files/alice.epub") + + if wordCount != want { + t.Fatalf(`Expected: %v, Got: %v, Error: %v`, want, wordCount, err) + } +}