[new] count words & stats, [new] refactor metadata, [new] human readable time

This commit is contained in:
Evan Reichard 2023-10-01 19:17:22 -04:00
parent 5a8bdacf4f
commit fafddeee8f
13 changed files with 806 additions and 239 deletions

View File

@ -76,6 +76,7 @@ func (api *API) registerWebAppRoutes() {
helperFuncs := template.FuncMap{ helperFuncs := template.FuncMap{
"GetSVGGraphData": graph.GetSVGGraphData, "GetSVGGraphData": graph.GetSVGGraphData,
"GetUTCOffsets": utils.GetUTCOffsets, "GetUTCOffsets": utils.GetUTCOffsets,
"NiceSeconds": utils.NiceSeconds,
} }
render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html") render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html")

View File

@ -75,21 +75,24 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
templateVarsBase["RouteName"] = routeName templateVarsBase["RouteName"] = routeName
return func(c *gin.Context) { return func(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser") var userID string
if rUser, _ := c.Get("AuthorizedUser"); rUser != nil {
userID = rUser.(string)
}
// Copy Base & Update // Copy Base & Update
templateVars := gin.H{} templateVars := gin.H{}
for k, v := range templateVarsBase { for k, v := range templateVarsBase {
templateVars[k] = v templateVars[k] = v
} }
templateVars["User"] = rUser templateVars["User"] = userID
// Potential URL Parameters // Potential URL Parameters
qParams := bindQueryParams(c) qParams := bindQueryParams(c)
if routeName == "documents" { if routeName == "documents" {
documents, err := api.DB.Queries.GetDocumentsWithStats(api.DB.Ctx, database.GetDocumentsWithStatsParams{ documents, err := api.DB.Queries.GetDocumentsWithStats(api.DB.Ctx, database.GetDocumentsWithStatsParams{
UserID: rUser.(string), UserID: userID,
Offset: (*qParams.Page - 1) * *qParams.Limit, Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit, Limit: *qParams.Limit,
}) })
@ -99,6 +102,10 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
return return
} }
if err = api.getDocumentsWordCount(documents); err != nil {
log.Error("[createAppResourcesRoute] Unable to Get Word Counts: ", err)
}
templateVars["Data"] = documents templateVars["Data"] = documents
} else if routeName == "document" { } else if routeName == "document" {
var rDocID requestDocumentID var rDocID requestDocumentID
@ -109,7 +116,7 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
} }
document, err := api.DB.Queries.GetDocumentWithStats(api.DB.Ctx, database.GetDocumentWithStatsParams{ document, err := api.DB.Queries.GetDocumentWithStats(api.DB.Ctx, database.GetDocumentWithStatsParams{
UserID: rUser.(string), UserID: userID,
DocumentID: rDocID.DocumentID, DocumentID: rDocID.DocumentID,
}) })
if err != nil { if err != nil {
@ -118,11 +125,21 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
return return
} }
statistics := gin.H{
"TotalTimeLeftSeconds": (document.TotalPages - document.CurrentPage) * document.SecondsPerPage,
"WordsPerMinute": "N/A",
}
if document.Words != nil && *document.Words != 0 {
statistics["WordsPerMinute"] = (*document.Words / document.TotalPages * document.ReadPages) / (document.TotalTimeSeconds / 60.0)
}
templateVars["RelBase"] = "../" templateVars["RelBase"] = "../"
templateVars["Data"] = document templateVars["Data"] = document
templateVars["Statistics"] = statistics
} else if routeName == "activity" { } else if routeName == "activity" {
activityFilter := database.GetActivityParams{ activityFilter := database.GetActivityParams{
UserID: rUser.(string), UserID: userID,
Offset: (*qParams.Page - 1) * *qParams.Limit, Offset: (*qParams.Page - 1) * *qParams.Limit,
Limit: *qParams.Limit, Limit: *qParams.Limit,
} }
@ -143,7 +160,7 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
} else if routeName == "home" { } else if routeName == "home" {
start_time := time.Now() start_time := time.Now()
weekly_streak, err := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{ weekly_streak, err := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{
UserID: rUser.(string), UserID: userID,
Window: "WEEK", Window: "WEEK",
}) })
if err != nil { if err != nil {
@ -153,7 +170,7 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
start_time = time.Now() start_time = time.Now()
daily_streak, err := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{ daily_streak, err := api.DB.Queries.GetUserWindowStreaks(api.DB.Ctx, database.GetUserWindowStreaksParams{
UserID: rUser.(string), UserID: userID,
Window: "DAY", Window: "DAY",
}) })
if err != nil { if err != nil {
@ -162,11 +179,11 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
log.Debug("GetUserWindowStreaks - DAY - ", time.Since(start_time)) log.Debug("GetUserWindowStreaks - DAY - ", time.Since(start_time))
start_time = time.Now() start_time = time.Now()
database_info, _ := api.DB.Queries.GetDatabaseInfo(api.DB.Ctx, rUser.(string)) database_info, _ := api.DB.Queries.GetDatabaseInfo(api.DB.Ctx, userID)
log.Debug("GetDatabaseInfo - ", time.Since(start_time)) log.Debug("GetDatabaseInfo - ", time.Since(start_time))
start_time = time.Now() start_time = time.Now()
read_graph_data, _ := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, rUser.(string)) read_graph_data, _ := api.DB.Queries.GetDailyReadStats(api.DB.Ctx, userID)
log.Debug("GetDailyReadStats - ", time.Since(start_time)) log.Debug("GetDailyReadStats - ", time.Since(start_time))
templateVars["Data"] = gin.H{ templateVars["Data"] = gin.H{
@ -176,14 +193,14 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
"GraphData": read_graph_data, "GraphData": read_graph_data,
} }
} else if routeName == "settings" { } else if routeName == "settings" {
user, err := api.DB.Queries.GetUser(api.DB.Ctx, rUser.(string)) user, err := api.DB.Queries.GetUser(api.DB.Ctx, userID)
if err != nil { if err != nil {
log.Error("[createAppResourcesRoute] GetUser DB Error:", err) log.Error("[createAppResourcesRoute] GetUser DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
return return
} }
devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, rUser.(string)) devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, userID)
if err != nil { if err != nil {
log.Error("[createAppResourcesRoute] GetDevices DB Error:", err) log.Error("[createAppResourcesRoute] GetDevices DB Error:", err)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"}) c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid Request"})
@ -248,16 +265,16 @@ func (api *API) getDocumentCover(c *gin.Context) {
var coverFile string = "UNKNOWN" var coverFile string = "UNKNOWN"
// Identify Documents & Save Covers // Identify Documents & Save Covers
metadataResults, err := metadata.GetMetadata(metadata.MetadataInfo{ metadataResults, err := metadata.SearchMetadata(metadata.GBOOK, metadata.MetadataInfo{
Title: document.Title, Title: document.Title,
Author: document.Author, Author: document.Author,
}) })
if err == nil && len(metadataResults) > 0 && metadataResults[0].GBID != nil { if err == nil && len(metadataResults) > 0 && metadataResults[0].ID != nil {
firstResult := metadataResults[0] firstResult := metadataResults[0]
// Save Cover // Save Cover
fileName, err := metadata.SaveCover(*firstResult.GBID, coverDir, document.ID, false) fileName, err := metadata.CacheCover(*firstResult.ID, coverDir, document.ID, false)
if err == nil { if err == nil {
coverFile = *fileName coverFile = *fileName
} }
@ -268,8 +285,8 @@ func (api *API) getDocumentCover(c *gin.Context) {
Title: firstResult.Title, Title: firstResult.Title,
Author: firstResult.Author, Author: firstResult.Author,
Description: firstResult.Description, Description: firstResult.Description,
Gbid: firstResult.GBID, Gbid: firstResult.ID,
Olid: firstResult.OLID, Olid: nil,
Isbn10: firstResult.ISBN10, Isbn10: firstResult.ISBN10,
Isbn13: firstResult.ISBN13, Isbn13: firstResult.ISBN13,
}); err != nil { }); err != nil {
@ -368,7 +385,7 @@ func (api *API) editDocument(c *gin.Context) {
coverFileName = &fileName coverFileName = &fileName
} else if rDocEdit.CoverGBID != nil { } else if rDocEdit.CoverGBID != nil {
var coverDir string = filepath.Join(api.Config.DataPath, "covers") var coverDir string = filepath.Join(api.Config.DataPath, "covers")
fileName, err := metadata.SaveCover(*rDocEdit.CoverGBID, coverDir, rDocID.DocumentID, true) fileName, err := metadata.CacheCover(*rDocEdit.CoverGBID, coverDir, rDocID.DocumentID, true)
if err == nil { if err == nil {
coverFileName = fileName coverFileName = fileName
} }
@ -456,7 +473,7 @@ func (api *API) identifyDocument(c *gin.Context) {
} }
// Get Metadata // Get Metadata
metadataResults, err := metadata.GetMetadata(metadata.MetadataInfo{ metadataResults, err := metadata.SearchMetadata(metadata.GBOOK, metadata.MetadataInfo{
Title: rDocIdentify.Title, Title: rDocIdentify.Title,
Author: rDocIdentify.Author, Author: rDocIdentify.Author,
ISBN10: rDocIdentify.ISBN, ISBN10: rDocIdentify.ISBN,
@ -471,8 +488,8 @@ func (api *API) identifyDocument(c *gin.Context) {
Title: firstResult.Title, Title: firstResult.Title,
Author: firstResult.Author, Author: firstResult.Author,
Description: firstResult.Description, Description: firstResult.Description,
Gbid: firstResult.GBID, Gbid: firstResult.ID,
Olid: firstResult.OLID, Olid: nil,
Isbn10: firstResult.ISBN10, Isbn10: firstResult.ISBN10,
Isbn13: firstResult.ISBN13, Isbn13: firstResult.ISBN13,
}); err != nil { }); err != nil {
@ -582,6 +599,45 @@ func (api *API) editSettings(c *gin.Context) {
c.HTML(http.StatusOK, "settings", templateVars) c.HTML(http.StatusOK, "settings", templateVars)
} }
func (api *API) getDocumentsWordCount(documents []database.GetDocumentsWithStatsRow) error {
// Do Transaction
tx, err := api.DB.DB.Begin()
if err != nil {
log.Error("[getDocumentsWordCount] Transaction Begin DB Error:", err)
return err
}
// Defer & Start Transaction
defer tx.Rollback()
qtx := api.DB.Queries.WithTx(tx)
for _, item := range documents {
if item.Words == nil && item.Filepath != nil {
filePath := filepath.Join(api.Config.DataPath, "documents", *item.Filepath)
wordCount, err := metadata.GetWordCount(filePath)
if err != nil {
log.Warn("[getDocumentsWordCount] Word Count Error - ", err)
} else {
if _, err := qtx.UpsertDocument(api.DB.Ctx, database.UpsertDocumentParams{
ID: item.ID,
Words: &wordCount,
}); err != nil {
log.Error("[getDocumentsWordCount] UpsertDocument DB Error - ", err)
return err
}
}
}
}
// Commit Transaction
if err := tx.Commit(); err != nil {
log.Error("[getDocumentsWordCount] Transaction Commit DB Error:", err)
return err
}
return nil
}
func bindQueryParams(c *gin.Context) queryParams { func bindQueryParams(c *gin.Context) queryParams {
var qParams queryParams var qParams queryParams
c.BindQuery(&qParams) c.BindQuery(&qParams)

View File

@ -39,6 +39,7 @@ type Document struct {
SeriesIndex *int64 `json:"series_index"` SeriesIndex *int64 `json:"series_index"`
Lang *string `json:"lang"` Lang *string `json:"lang"`
Description *string `json:"description"` Description *string `json:"description"`
Words *int64 `json:"words"`
Gbid *string `json:"gbid"` Gbid *string `json:"gbid"`
Olid *string `json:"-"` Olid *string `json:"-"`
Isbn10 *string `json:"isbn10"` Isbn10 *string `json:"isbn10"`

View File

@ -41,12 +41,13 @@ INSERT INTO documents (
series_index, series_index,
lang, lang,
description, description,
words,
olid, olid,
gbid, gbid,
isbn10, isbn10,
isbn13 isbn13
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO UPDATE ON CONFLICT DO UPDATE
SET SET
md5 = COALESCE(excluded.md5, md5), md5 = COALESCE(excluded.md5, md5),
@ -58,6 +59,7 @@ SET
series_index = COALESCE(excluded.series_index, series_index), series_index = COALESCE(excluded.series_index, series_index),
lang = COALESCE(excluded.lang, lang), lang = COALESCE(excluded.lang, lang),
description = COALESCE(excluded.description, description), description = COALESCE(excluded.description, description),
words = COALESCE(excluded.words, words),
olid = COALESCE(excluded.olid, olid), olid = COALESCE(excluded.olid, olid),
gbid = COALESCE(excluded.gbid, gbid), gbid = COALESCE(excluded.gbid, gbid),
isbn10 = COALESCE(excluded.isbn10, isbn10), isbn10 = COALESCE(excluded.isbn10, isbn10),
@ -188,10 +190,15 @@ OFFSET $offset;
WITH true_progress AS ( WITH true_progress AS (
SELECT SELECT
start_time AS last_read, start_time AS last_read,
SUM(duration) / 60 AS total_time_minutes, SUM(duration) AS total_time_seconds,
document_id, document_id,
current_page, current_page,
total_pages, total_pages,
-- Determine Read Pages
COUNT(DISTINCT current_page) AS read_pages,
-- Derive Percentage of Book
ROUND(CAST(current_page AS REAL) / CAST(total_pages AS REAL) * 100, 2) AS percentage ROUND(CAST(current_page AS REAL) / CAST(total_pages AS REAL) * 100, 2) AS percentage
FROM activity FROM activity
WHERE user_id = $user_id WHERE user_id = $user_id
@ -205,9 +212,19 @@ SELECT
CAST(IFNULL(current_page, 0) AS INTEGER) AS current_page, CAST(IFNULL(current_page, 0) AS INTEGER) AS current_page,
CAST(IFNULL(total_pages, 0) AS INTEGER) AS total_pages, CAST(IFNULL(total_pages, 0) AS INTEGER) AS total_pages,
CAST(IFNULL(total_time_minutes, 0) AS INTEGER) AS total_time_minutes, CAST(IFNULL(total_time_seconds, 0) AS INTEGER) AS total_time_seconds,
CAST(DATETIME(IFNULL(last_read, "1970-01-01"), time_offset) AS TEXT) AS last_read, CAST(DATETIME(IFNULL(last_read, "1970-01-01"), time_offset) AS TEXT) AS last_read,
CAST(IFNULL(read_pages, 0) AS INTEGER) AS read_pages,
-- Calculate Seconds / Page
-- 1. Calculate Total Time in Seconds (Sum Duration in Activity)
-- 2. Divide by Read Pages (Distinct Pages in Activity)
CAST(CASE
WHEN total_time_seconds IS NULL THEN 0.0
ELSE ROUND(CAST(total_time_seconds AS REAL) / CAST(read_pages AS REAL))
END AS INTEGER) AS seconds_per_page,
-- Arbitrarily >97% is Complete
CAST(CASE CAST(CASE
WHEN percentage > 97.0 THEN 100.0 WHEN percentage > 97.0 THEN 100.0
WHEN percentage IS NULL THEN 0.0 WHEN percentage IS NULL THEN 0.0
@ -225,7 +242,7 @@ LIMIT 1;
WITH true_progress AS ( WITH true_progress AS (
SELECT SELECT
start_time AS last_read, start_time AS last_read,
SUM(duration) / 60 AS total_time_minutes, SUM(duration) AS total_time_seconds,
document_id, document_id,
current_page, current_page,
total_pages, total_pages,
@ -240,7 +257,7 @@ SELECT
CAST(IFNULL(current_page, 0) AS INTEGER) AS current_page, CAST(IFNULL(current_page, 0) AS INTEGER) AS current_page,
CAST(IFNULL(total_pages, 0) AS INTEGER) AS total_pages, CAST(IFNULL(total_pages, 0) AS INTEGER) AS total_pages,
CAST(IFNULL(total_time_minutes, 0) AS INTEGER) AS total_time_minutes, CAST(IFNULL(total_time_seconds, 0) AS INTEGER) AS total_time_seconds,
CAST(DATETIME(IFNULL(last_read, "1970-01-01"), time_offset) AS TEXT) AS last_read, CAST(DATETIME(IFNULL(last_read, "1970-01-01"), time_offset) AS TEXT) AS last_read,
CAST(CASE CAST(CASE

View File

@ -417,7 +417,7 @@ func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRo
} }
const getDocument = `-- name: GetDocument :one const getDocument = `-- name: GetDocument :one
SELECT id, md5, filepath, coverfile, title, author, series, series_index, lang, description, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents SELECT id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents
WHERE id = ?1 LIMIT 1 WHERE id = ?1 LIMIT 1
` `
@ -435,6 +435,7 @@ func (q *Queries) GetDocument(ctx context.Context, documentID string) (Document,
&i.SeriesIndex, &i.SeriesIndex,
&i.Lang, &i.Lang,
&i.Description, &i.Description,
&i.Words,
&i.Gbid, &i.Gbid,
&i.Olid, &i.Olid,
&i.Isbn10, &i.Isbn10,
@ -543,10 +544,15 @@ const getDocumentWithStats = `-- name: GetDocumentWithStats :one
WITH true_progress AS ( WITH true_progress AS (
SELECT SELECT
start_time AS last_read, start_time AS last_read,
SUM(duration) / 60 AS total_time_minutes, SUM(duration) AS total_time_seconds,
document_id, document_id,
current_page, current_page,
total_pages, total_pages,
-- Determine Read Pages
COUNT(DISTINCT current_page) AS read_pages,
-- Derive Percentage of Book
ROUND(CAST(current_page AS REAL) / CAST(total_pages AS REAL) * 100, 2) AS percentage ROUND(CAST(current_page AS REAL) / CAST(total_pages AS REAL) * 100, 2) AS percentage
FROM activity FROM activity
WHERE user_id = ?1 WHERE user_id = ?1
@ -556,13 +562,23 @@ WITH true_progress AS (
LIMIT 1 LIMIT 1
) )
SELECT SELECT
documents.id, documents.md5, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at, documents.id, documents.md5, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.words, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at,
CAST(IFNULL(current_page, 0) AS INTEGER) AS current_page, CAST(IFNULL(current_page, 0) AS INTEGER) AS current_page,
CAST(IFNULL(total_pages, 0) AS INTEGER) AS total_pages, CAST(IFNULL(total_pages, 0) AS INTEGER) AS total_pages,
CAST(IFNULL(total_time_minutes, 0) AS INTEGER) AS total_time_minutes, CAST(IFNULL(total_time_seconds, 0) AS INTEGER) AS total_time_seconds,
CAST(DATETIME(IFNULL(last_read, "1970-01-01"), time_offset) AS TEXT) AS last_read, CAST(DATETIME(IFNULL(last_read, "1970-01-01"), time_offset) AS TEXT) AS last_read,
CAST(IFNULL(read_pages, 0) AS INTEGER) AS read_pages,
-- Calculate Seconds / Page
-- 1. Calculate Total Time in Seconds (Sum Duration in Activity)
-- 2. Divide by Read Pages (Distinct Pages in Activity)
CAST(CASE
WHEN total_time_seconds IS NULL THEN 0.0
ELSE ROUND(CAST(total_time_seconds AS REAL) / CAST(read_pages AS REAL))
END AS INTEGER) AS seconds_per_page,
-- Arbitrarily >97% is Complete
CAST(CASE CAST(CASE
WHEN percentage > 97.0 THEN 100.0 WHEN percentage > 97.0 THEN 100.0
WHEN percentage IS NULL THEN 0.0 WHEN percentage IS NULL THEN 0.0
@ -593,6 +609,7 @@ type GetDocumentWithStatsRow struct {
SeriesIndex *int64 `json:"series_index"` SeriesIndex *int64 `json:"series_index"`
Lang *string `json:"lang"` Lang *string `json:"lang"`
Description *string `json:"description"` Description *string `json:"description"`
Words *int64 `json:"words"`
Gbid *string `json:"gbid"` Gbid *string `json:"gbid"`
Olid *string `json:"-"` Olid *string `json:"-"`
Isbn10 *string `json:"isbn10"` Isbn10 *string `json:"isbn10"`
@ -603,8 +620,10 @@ type GetDocumentWithStatsRow struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
CurrentPage int64 `json:"current_page"` CurrentPage int64 `json:"current_page"`
TotalPages int64 `json:"total_pages"` TotalPages int64 `json:"total_pages"`
TotalTimeMinutes int64 `json:"total_time_minutes"` TotalTimeSeconds int64 `json:"total_time_seconds"`
LastRead string `json:"last_read"` LastRead string `json:"last_read"`
ReadPages int64 `json:"read_pages"`
SecondsPerPage int64 `json:"seconds_per_page"`
Percentage float64 `json:"percentage"` Percentage float64 `json:"percentage"`
} }
@ -622,6 +641,7 @@ func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithS
&i.SeriesIndex, &i.SeriesIndex,
&i.Lang, &i.Lang,
&i.Description, &i.Description,
&i.Words,
&i.Gbid, &i.Gbid,
&i.Olid, &i.Olid,
&i.Isbn10, &i.Isbn10,
@ -632,15 +652,17 @@ func (q *Queries) GetDocumentWithStats(ctx context.Context, arg GetDocumentWithS
&i.CreatedAt, &i.CreatedAt,
&i.CurrentPage, &i.CurrentPage,
&i.TotalPages, &i.TotalPages,
&i.TotalTimeMinutes, &i.TotalTimeSeconds,
&i.LastRead, &i.LastRead,
&i.ReadPages,
&i.SecondsPerPage,
&i.Percentage, &i.Percentage,
) )
return i, err return i, err
} }
const getDocuments = `-- name: GetDocuments :many const getDocuments = `-- name: GetDocuments :many
SELECT id, md5, filepath, coverfile, title, author, series, series_index, lang, description, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents SELECT id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at FROM documents
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT ?2 LIMIT ?2
OFFSET ?1 OFFSET ?1
@ -671,6 +693,7 @@ func (q *Queries) GetDocuments(ctx context.Context, arg GetDocumentsParams) ([]D
&i.SeriesIndex, &i.SeriesIndex,
&i.Lang, &i.Lang,
&i.Description, &i.Description,
&i.Words,
&i.Gbid, &i.Gbid,
&i.Olid, &i.Olid,
&i.Isbn10, &i.Isbn10,
@ -697,7 +720,7 @@ const getDocumentsWithStats = `-- name: GetDocumentsWithStats :many
WITH true_progress AS ( WITH true_progress AS (
SELECT SELECT
start_time AS last_read, start_time AS last_read,
SUM(duration) / 60 AS total_time_minutes, SUM(duration) AS total_time_seconds,
document_id, document_id,
current_page, current_page,
total_pages, total_pages,
@ -708,11 +731,11 @@ WITH true_progress AS (
HAVING MAX(start_time) HAVING MAX(start_time)
) )
SELECT SELECT
documents.id, documents.md5, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at, documents.id, documents.md5, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.words, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at,
CAST(IFNULL(current_page, 0) AS INTEGER) AS current_page, CAST(IFNULL(current_page, 0) AS INTEGER) AS current_page,
CAST(IFNULL(total_pages, 0) AS INTEGER) AS total_pages, CAST(IFNULL(total_pages, 0) AS INTEGER) AS total_pages,
CAST(IFNULL(total_time_minutes, 0) AS INTEGER) AS total_time_minutes, CAST(IFNULL(total_time_seconds, 0) AS INTEGER) AS total_time_seconds,
CAST(DATETIME(IFNULL(last_read, "1970-01-01"), time_offset) AS TEXT) AS last_read, CAST(DATETIME(IFNULL(last_read, "1970-01-01"), time_offset) AS TEXT) AS last_read,
CAST(CASE CAST(CASE
@ -747,6 +770,7 @@ type GetDocumentsWithStatsRow struct {
SeriesIndex *int64 `json:"series_index"` SeriesIndex *int64 `json:"series_index"`
Lang *string `json:"lang"` Lang *string `json:"lang"`
Description *string `json:"description"` Description *string `json:"description"`
Words *int64 `json:"words"`
Gbid *string `json:"gbid"` Gbid *string `json:"gbid"`
Olid *string `json:"-"` Olid *string `json:"-"`
Isbn10 *string `json:"isbn10"` Isbn10 *string `json:"isbn10"`
@ -757,7 +781,7 @@ type GetDocumentsWithStatsRow struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
CurrentPage int64 `json:"current_page"` CurrentPage int64 `json:"current_page"`
TotalPages int64 `json:"total_pages"` TotalPages int64 `json:"total_pages"`
TotalTimeMinutes int64 `json:"total_time_minutes"` TotalTimeSeconds int64 `json:"total_time_seconds"`
LastRead string `json:"last_read"` LastRead string `json:"last_read"`
Percentage float64 `json:"percentage"` Percentage float64 `json:"percentage"`
} }
@ -782,6 +806,7 @@ func (q *Queries) GetDocumentsWithStats(ctx context.Context, arg GetDocumentsWit
&i.SeriesIndex, &i.SeriesIndex,
&i.Lang, &i.Lang,
&i.Description, &i.Description,
&i.Words,
&i.Gbid, &i.Gbid,
&i.Olid, &i.Olid,
&i.Isbn10, &i.Isbn10,
@ -792,7 +817,7 @@ func (q *Queries) GetDocumentsWithStats(ctx context.Context, arg GetDocumentsWit
&i.CreatedAt, &i.CreatedAt,
&i.CurrentPage, &i.CurrentPage,
&i.TotalPages, &i.TotalPages,
&i.TotalTimeMinutes, &i.TotalTimeSeconds,
&i.LastRead, &i.LastRead,
&i.Percentage, &i.Percentage,
); err != nil { ); err != nil {
@ -830,7 +855,7 @@ func (q *Queries) GetLastActivity(ctx context.Context, arg GetLastActivityParams
} }
const getMissingDocuments = `-- name: GetMissingDocuments :many const getMissingDocuments = `-- name: GetMissingDocuments :many
SELECT documents.id, documents.md5, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at FROM documents SELECT documents.id, documents.md5, documents.filepath, documents.coverfile, documents.title, documents.author, documents.series, documents.series_index, documents.lang, documents.description, documents.words, documents.gbid, documents.olid, documents.isbn10, documents.isbn13, documents.synced, documents.deleted, documents.updated_at, documents.created_at FROM documents
WHERE WHERE
documents.filepath IS NOT NULL documents.filepath IS NOT NULL
AND documents.deleted = false AND documents.deleted = false
@ -867,6 +892,7 @@ func (q *Queries) GetMissingDocuments(ctx context.Context, documentIds []string)
&i.SeriesIndex, &i.SeriesIndex,
&i.Lang, &i.Lang,
&i.Description, &i.Description,
&i.Words,
&i.Gbid, &i.Gbid,
&i.Olid, &i.Olid,
&i.Isbn10, &i.Isbn10,
@ -1157,7 +1183,7 @@ UPDATE documents
SET SET
deleted = ?1 deleted = ?1
WHERE id = ?2 WHERE id = ?2
RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at
` `
type UpdateDocumentDeletedParams struct { type UpdateDocumentDeletedParams struct {
@ -1179,6 +1205,7 @@ func (q *Queries) UpdateDocumentDeleted(ctx context.Context, arg UpdateDocumentD
&i.SeriesIndex, &i.SeriesIndex,
&i.Lang, &i.Lang,
&i.Description, &i.Description,
&i.Words,
&i.Gbid, &i.Gbid,
&i.Olid, &i.Olid,
&i.Isbn10, &i.Isbn10,
@ -1196,7 +1223,7 @@ UPDATE documents
SET SET
synced = ?1 synced = ?1
WHERE id = ?2 WHERE id = ?2
RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at
` `
type UpdateDocumentSyncParams struct { type UpdateDocumentSyncParams struct {
@ -1218,6 +1245,7 @@ func (q *Queries) UpdateDocumentSync(ctx context.Context, arg UpdateDocumentSync
&i.SeriesIndex, &i.SeriesIndex,
&i.Lang, &i.Lang,
&i.Description, &i.Description,
&i.Words,
&i.Gbid, &i.Gbid,
&i.Olid, &i.Olid,
&i.Isbn10, &i.Isbn10,
@ -1338,12 +1366,13 @@ INSERT INTO documents (
series_index, series_index,
lang, lang,
description, description,
words,
olid, olid,
gbid, gbid,
isbn10, isbn10,
isbn13 isbn13
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT DO UPDATE ON CONFLICT DO UPDATE
SET SET
md5 = COALESCE(excluded.md5, md5), md5 = COALESCE(excluded.md5, md5),
@ -1355,11 +1384,12 @@ SET
series_index = COALESCE(excluded.series_index, series_index), series_index = COALESCE(excluded.series_index, series_index),
lang = COALESCE(excluded.lang, lang), lang = COALESCE(excluded.lang, lang),
description = COALESCE(excluded.description, description), description = COALESCE(excluded.description, description),
words = COALESCE(excluded.words, words),
olid = COALESCE(excluded.olid, olid), olid = COALESCE(excluded.olid, olid),
gbid = COALESCE(excluded.gbid, gbid), gbid = COALESCE(excluded.gbid, gbid),
isbn10 = COALESCE(excluded.isbn10, isbn10), isbn10 = COALESCE(excluded.isbn10, isbn10),
isbn13 = COALESCE(excluded.isbn13, isbn13) isbn13 = COALESCE(excluded.isbn13, isbn13)
RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at RETURNING id, md5, filepath, coverfile, title, author, series, series_index, lang, description, words, gbid, olid, isbn10, isbn13, synced, deleted, updated_at, created_at
` `
type UpsertDocumentParams struct { type UpsertDocumentParams struct {
@ -1373,6 +1403,7 @@ type UpsertDocumentParams struct {
SeriesIndex *int64 `json:"series_index"` SeriesIndex *int64 `json:"series_index"`
Lang *string `json:"lang"` Lang *string `json:"lang"`
Description *string `json:"description"` Description *string `json:"description"`
Words *int64 `json:"words"`
Olid *string `json:"-"` Olid *string `json:"-"`
Gbid *string `json:"gbid"` Gbid *string `json:"gbid"`
Isbn10 *string `json:"isbn10"` Isbn10 *string `json:"isbn10"`
@ -1391,6 +1422,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams)
arg.SeriesIndex, arg.SeriesIndex,
arg.Lang, arg.Lang,
arg.Description, arg.Description,
arg.Words,
arg.Olid, arg.Olid,
arg.Gbid, arg.Gbid,
arg.Isbn10, arg.Isbn10,
@ -1408,6 +1440,7 @@ func (q *Queries) UpsertDocument(ctx context.Context, arg UpsertDocumentParams)
&i.SeriesIndex, &i.SeriesIndex,
&i.Lang, &i.Lang,
&i.Description, &i.Description,
&i.Words,
&i.Gbid, &i.Gbid,
&i.Olid, &i.Olid,
&i.Isbn10, &i.Isbn10,

View File

@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS documents (
series_index INTEGER, series_index INTEGER,
lang TEXT, lang TEXT,
description TEXT, description TEXT,
words INTEGER,
gbid TEXT, gbid TEXT,
olid TEXT, olid TEXT,

330
metadata/epub.go Normal file
View File

@ -0,0 +1,330 @@
/*
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"
"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)
if err != nil {
return nil, err
}
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)
}
return completeCount
}
func countWords(tokenizer html.Tokenizer) int64 {
var err error
var totalWords int64
for {
tokenType := tokenizer.Next()
token := tokenizer.Token()
if tokenType == html.TextToken {
currStr := string(token.Data)
totalWords = totalWords + int64(len(strings.Fields(currStr)))
} else if tokenType == html.ErrorToken {
err = tokenizer.Err()
}
if err == io.EOF {
return totalWords
} else if err != nil {
return 0
}
}
}
/*
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)
}
*/

200
metadata/gbooks.go Normal file
View File

@ -0,0 +1,200 @@
package metadata
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
log "github.com/sirupsen/logrus"
)
type gBooksIdentifiers struct {
Type string `json:"type"`
Identifier string `json:"identifier"`
}
type gBooksInfo struct {
Title string `json:"title"`
Authors []string `json:"authors"`
Description string `json:"description"`
Identifiers []gBooksIdentifiers `json:"industryIdentifiers"`
}
type gBooksQueryItem struct {
ID string `json:"id"`
Info gBooksInfo `json:"volumeInfo"`
}
type gBooksQueryResponse struct {
TotalItems int `json:"totalItems"`
Items []gBooksQueryItem `json:"items"`
}
const GBOOKS_QUERY_URL string = "https://www.googleapis.com/books/v1/volumes?q=%s"
const GBOOKS_GBID_INFO_URL string = "https://www.googleapis.com/books/v1/volumes/%s"
const GBOOKS_GBID_COVER_URL string = "https://books.google.com/books/content/images/frontcover/%s?fife=w480-h690"
func getGBooksMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) {
var queryResults []gBooksQueryItem
if metadataSearch.ID != nil {
// Use GBID
resp, err := performGBIDRequest(*metadataSearch.ID)
if err != nil {
return nil, err
}
queryResults = []gBooksQueryItem{*resp}
} else if metadataSearch.ISBN13 != nil {
searchQuery := "isbn:" + *metadataSearch.ISBN13
resp, err := performSearchRequest(searchQuery)
if err != nil {
return nil, err
}
queryResults = resp.Items
} else if metadataSearch.ISBN10 != nil {
searchQuery := "isbn:" + *metadataSearch.ISBN10
resp, err := performSearchRequest(searchQuery)
if err != nil {
return nil, err
}
queryResults = resp.Items
} else if metadataSearch.Title != nil || metadataSearch.Author != nil {
var searchQuery string
if metadataSearch.Title != nil {
searchQuery = searchQuery + *metadataSearch.Title
}
if metadataSearch.Author != nil {
searchQuery = searchQuery + " " + *metadataSearch.Author
}
// Escape & Trim
searchQuery = url.QueryEscape(strings.TrimSpace(searchQuery))
resp, err := performSearchRequest(searchQuery)
if err != nil {
return nil, err
}
queryResults = resp.Items
} else {
return nil, errors.New("Invalid Data")
}
// Normalize Data
allMetadata := []MetadataInfo{}
for i := range queryResults {
item := queryResults[i] // Range Value Pointer Issue
itemResult := MetadataInfo{
ID: &item.ID,
Title: &item.Info.Title,
Description: &item.Info.Description,
}
if len(item.Info.Authors) > 0 {
itemResult.Author = &item.Info.Authors[0]
}
for i := range item.Info.Identifiers {
item := item.Info.Identifiers[i] // Range Value Pointer Issue
if itemResult.ISBN10 != nil && itemResult.ISBN13 != nil {
break
} else if itemResult.ISBN10 == nil && item.Type == "ISBN_10" {
itemResult.ISBN10 = &item.Identifier
} else if itemResult.ISBN13 == nil && item.Type == "ISBN_13" {
itemResult.ISBN13 = &item.Identifier
}
}
allMetadata = append(allMetadata, itemResult)
}
return allMetadata, nil
}
func saveGBooksCover(gbid string, coverFilePath string, overwrite bool) error {
// Validate File Doesn't Exists
_, err := os.Stat(coverFilePath)
if err == nil && overwrite == false {
log.Warn("[saveGBooksCover] File Alreads Exists")
return nil
}
// Create File
out, err := os.Create(coverFilePath)
if err != nil {
log.Error("[saveGBooksCover] File Create Error")
return errors.New("File Failure")
}
defer out.Close()
// Download File
log.Info("[saveGBooksCover] Downloading Cover")
coverURL := fmt.Sprintf(GBOOKS_GBID_COVER_URL, gbid)
resp, err := http.Get(coverURL)
if err != nil {
log.Error("[saveGBooksCover] Cover URL API Failure")
return errors.New("API Failure")
}
defer resp.Body.Close()
// Copy File to Disk
log.Info("[saveGBooksCover] Saving Cover")
_, err = io.Copy(out, resp.Body)
if err != nil {
log.Error("[saveGBooksCover] File Copy Error")
return errors.New("File Failure")
}
return nil
}
func performSearchRequest(searchQuery string) (*gBooksQueryResponse, error) {
apiQuery := fmt.Sprintf(GBOOKS_QUERY_URL, searchQuery)
log.Info("[performSearchRequest] Acquiring Metadata: ", apiQuery)
resp, err := http.Get(apiQuery)
if err != nil {
log.Error("[performSearchRequest] Google Books Query URL API Failure")
return nil, errors.New("API Failure")
}
parsedResp := gBooksQueryResponse{}
err = json.NewDecoder(resp.Body).Decode(&parsedResp)
if err != nil {
log.Error("[performSearchRequest] Google Books Query API Decode Failure")
return nil, errors.New("API Failure")
}
if len(parsedResp.Items) == 0 {
log.Warn("[performSearchRequest] No Results")
return nil, errors.New("No Results")
}
return &parsedResp, nil
}
func performGBIDRequest(id string) (*gBooksQueryItem, error) {
apiQuery := fmt.Sprintf(GBOOKS_GBID_INFO_URL, id)
log.Info("[performGBIDRequest] Acquiring CoverID")
resp, err := http.Get(apiQuery)
if err != nil {
log.Error("[performGBIDRequest] Cover URL API Failure")
return nil, errors.New("API Failure")
}
parsedResp := gBooksQueryItem{}
err = json.NewDecoder(resp.Body).Decode(&parsedResp)
if err != nil {
log.Error("[performGBIDRequest] Google Books ID API Decode Failure")
return nil, errors.New("API Failure")
}
return &parsedResp, nil
}

View File

@ -1,217 +1,72 @@
package metadata package metadata
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath" "path/filepath"
"strings"
log "github.com/sirupsen/logrus" "github.com/gabriel-vasile/mimetype"
)
type Source int
const (
GBOOK Source = iota
OLIB
) )
type MetadataInfo struct { type MetadataInfo struct {
ID *string
Title *string Title *string
Author *string Author *string
Description *string Description *string
GBID *string
OLID *string
ISBN10 *string ISBN10 *string
ISBN13 *string ISBN13 *string
} }
type gBooksIdentifiers struct { func CacheCover(gbid string, coverDir string, documentID string, overwrite bool) (*string, error) {
Type string `json:"type"` // Get Filepath
Identifier string `json:"identifier"`
}
type gBooksInfo struct {
Title string `json:"title"`
Authors []string `json:"authors"`
Description string `json:"description"`
Identifiers []gBooksIdentifiers `json:"industryIdentifiers"`
}
type gBooksQueryItem struct {
ID string `json:"id"`
Info gBooksInfo `json:"volumeInfo"`
}
type gBooksQueryResponse struct {
TotalItems int `json:"totalItems"`
Items []gBooksQueryItem `json:"items"`
}
const GBOOKS_QUERY_URL string = "https://www.googleapis.com/books/v1/volumes?q=%s"
const GBOOKS_GBID_INFO_URL string = "https://www.googleapis.com/books/v1/volumes/%s"
const GBOOKS_GBID_COVER_URL string = "https://books.google.com/books/content/images/frontcover/%s?fife=w480-h690"
func GetMetadata(metadataSearch MetadataInfo) ([]MetadataInfo, error) {
var queryResults []gBooksQueryItem
if metadataSearch.GBID != nil {
// Use GBID
resp, err := performGBIDRequest(*metadataSearch.GBID)
if err != nil {
return nil, err
}
queryResults = []gBooksQueryItem{*resp}
} else if metadataSearch.ISBN13 != nil {
searchQuery := "isbn:" + *metadataSearch.ISBN13
resp, err := performSearchRequest(searchQuery)
if err != nil {
return nil, err
}
queryResults = resp.Items
} else if metadataSearch.ISBN10 != nil {
searchQuery := "isbn:" + *metadataSearch.ISBN10
resp, err := performSearchRequest(searchQuery)
if err != nil {
return nil, err
}
queryResults = resp.Items
} else if metadataSearch.Title != nil || metadataSearch.Author != nil {
var searchQuery string
if metadataSearch.Title != nil {
searchQuery = searchQuery + *metadataSearch.Title
}
if metadataSearch.Author != nil {
searchQuery = searchQuery + " " + *metadataSearch.Author
}
// Escape & Trim
searchQuery = url.QueryEscape(strings.TrimSpace(searchQuery))
resp, err := performSearchRequest(searchQuery)
if err != nil {
return nil, err
}
queryResults = resp.Items
} else {
return nil, errors.New("Invalid Data")
}
// Normalize Data
allMetadata := []MetadataInfo{}
for i := range queryResults {
item := queryResults[i] // Range Value Pointer Issue
itemResult := MetadataInfo{
GBID: &item.ID,
Title: &item.Info.Title,
Description: &item.Info.Description,
}
if len(item.Info.Authors) > 0 {
itemResult.Author = &item.Info.Authors[0]
}
for i := range item.Info.Identifiers {
item := item.Info.Identifiers[i] // Range Value Pointer Issue
if itemResult.ISBN10 != nil && itemResult.ISBN13 != nil {
break
} else if itemResult.ISBN10 == nil && item.Type == "ISBN_10" {
itemResult.ISBN10 = &item.Identifier
} else if itemResult.ISBN13 == nil && item.Type == "ISBN_13" {
itemResult.ISBN13 = &item.Identifier
}
}
allMetadata = append(allMetadata, itemResult)
}
return allMetadata, nil
}
func SaveCover(gbid string, coverDir string, documentID string, overwrite bool) (*string, error) {
// Google Books -> JPG
coverFile := "." + filepath.Clean(fmt.Sprintf("/%s.jpg", documentID)) coverFile := "." + filepath.Clean(fmt.Sprintf("/%s.jpg", documentID))
coverFilePath := filepath.Join(coverDir, coverFile) coverFilePath := filepath.Join(coverDir, coverFile)
// Validate File Doesn't Exists // Save Google Books
_, err := os.Stat(coverFilePath) if err := saveGBooksCover(gbid, coverFilePath, overwrite); err != nil {
if err == nil && overwrite == false { return nil, err
log.Warn("[SaveCover] File Alreads Exists")
return &coverFile, nil
} }
// Create File // TODO - Refactor & Allow Open Library / Alternative Sources
out, err := os.Create(coverFilePath)
if err != nil {
log.Error("[SaveCover] File Create Error")
return nil, errors.New("File Failure")
}
defer out.Close()
// Download File
log.Info("[SaveCover] Downloading Cover")
coverURL := fmt.Sprintf(GBOOKS_GBID_COVER_URL, gbid)
resp, err := http.Get(coverURL)
if err != nil {
log.Error("[SaveCover] Cover URL API Failure")
return nil, errors.New("API Failure")
}
defer resp.Body.Close()
// Copy File to Disk
log.Info("[SaveCover] Saving Cover")
_, err = io.Copy(out, resp.Body)
if err != nil {
log.Error("[SaveCover] File Copy Error")
return nil, errors.New("File Failure")
}
// Return FilePath
return &coverFile, nil return &coverFile, nil
} }
func performSearchRequest(searchQuery string) (*gBooksQueryResponse, error) { func SearchMetadata(s Source, metadataSearch MetadataInfo) ([]MetadataInfo, error) {
apiQuery := fmt.Sprintf(GBOOKS_QUERY_URL, searchQuery) switch s {
log.Info("[performSearchRequest] Acquiring Metadata: ", apiQuery) case GBOOK:
resp, err := http.Get(apiQuery) return getGBooksMetadata(metadataSearch)
if err != nil { case OLIB:
log.Error("[performSearchRequest] Google Books Query URL API Failure") return nil, errors.New("Not implemented")
return nil, errors.New("API Failure") default:
} return nil, errors.New("Not implemented")
parsedResp := gBooksQueryResponse{}
err = json.NewDecoder(resp.Body).Decode(&parsedResp)
if err != nil {
log.Error("[performSearchRequest] Google Books Query API Decode Failure")
return nil, errors.New("API Failure")
} }
if len(parsedResp.Items) == 0 {
log.Warn("[performSearchRequest] No Results")
return nil, errors.New("No Results")
}
return &parsedResp, nil
} }
func performGBIDRequest(id string) (*gBooksQueryItem, error) { func GetWordCount(filepath string) (int64, error) {
apiQuery := fmt.Sprintf(GBOOKS_GBID_INFO_URL, id) fileMime, err := mimetype.DetectFile(filepath)
log.Info("[performGBIDRequest] Acquiring CoverID")
resp, err := http.Get(apiQuery)
if err != nil { if err != nil {
log.Error("[performGBIDRequest] Cover URL API Failure") return 0, err
return nil, errors.New("API Failure")
} }
parsedResp := gBooksQueryItem{} if fileExtension := fileMime.Extension(); fileExtension == ".epub" {
err = json.NewDecoder(resp.Body).Decode(&parsedResp) rc, err := OpenEPUBReader(filepath)
if err != nil { if err != nil {
log.Error("[performGBIDRequest] Google Books ID API Decode Failure") return 0, err
return nil, errors.New("API Failure")
} }
return &parsedResp, nil rf := rc.Rootfiles[0]
totalWords := rf.CountWords()
return totalWords, nil
} else {
return 0, errors.New("Invalid Extension")
}
} }

View File

@ -46,6 +46,10 @@ sql:
go_type: go_type:
type: "string" type: "string"
pointer: true pointer: true
- column: "documents.words"
go_type:
type: "int64"
pointer: true
- column: "documents.olid" - column: "documents.olid"
go_type: go_type:
type: "string" type: "string"

View File

@ -295,10 +295,50 @@
{{ or .Data.Author "N/A" }} {{ or .Data.Author "N/A" }}
</p> </p>
</div> </div>
<div> <div class="relative">
<p class="text-gray-500">Time Read</p> <div class="text-gray-500 inline-flex gap-2 relative">
<p>Time Read</p>
<label class="my-auto" for="progress-info-button">
<svg
width="18"
height="18"
class="cursor-pointer hover:text-gray-800 dark:hover:text-gray-100"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22ZM12 17.75C12.4142 17.75 12.75 17.4142 12.75 17V11C12.75 10.5858 12.4142 10.25 12 10.25C11.5858 10.25 11.25 10.5858 11.25 11V17C11.25 17.4142 11.5858 17.75 12 17.75ZM12 7C12.5523 7 13 7.44772 13 8C13 8.55228 12.5523 9 12 9C11.4477 9 11 8.55228 11 8C11 7.44772 11.4477 7 12 7Z"
/>
</svg>
</label>
<input type="checkbox" id="progress-info-button" class="hidden css-button"/>
<div class="absolute z-30 top-7 right-0 p-3 transition-all duration-200 bg-gray-200 rounded shadow-lg shadow-gray-500 dark:shadow-gray-900 dark:bg-gray-600">
<div class="text-xs flex">
<p class="text-gray-400 w-32">Seconds / Page</p>
<p class="font-medium dark:text-white">
{{ .Data.SecondsPerPage }}
</p>
</div>
<div class="text-xs flex">
<p class="text-gray-400 w-32">Words / Minute</p>
<p class="font-medium dark:text-white">
{{ .Statistics.WordsPerMinute }}
</p>
</div>
<div class="text-xs flex">
<p class="text-gray-400 w-32">Est. Time Left</p>
<p class="font-medium dark:text-white whitespace-nowrap">
{{ NiceSeconds .Statistics.TotalTimeLeftSeconds }}
</p>
</div>
</div>
</div>
<p class="font-medium text-lg"> <p class="font-medium text-lg">
{{ .Data.TotalTimeMinutes }} Minutes {{ NiceSeconds .Data.TotalTimeSeconds }}
</p> </p>
</div> </div>
<div> <div>
@ -410,7 +450,7 @@
Cover Cover
</dt> </dt>
<dd class="mt-1 text-sm sm:mt-0 sm:col-span-2"> <dd class="mt-1 text-sm sm:mt-0 sm:col-span-2">
<img class="rounded object-fill h-32" src="https://books.google.com/books/content/images/frontcover/{{ .Metadata.GBID }}?fife=w480-h690"></img> <img class="rounded object-fill h-32" src="https://books.google.com/books/content/images/frontcover/{{ .Metadata.ID }}?fife=w480-h690"></img>
</dd> </dd>
</div> </div>
<div class="p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6"> <div class="p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6">
@ -460,7 +500,7 @@
<input type="text" id="description" name="description" value="{{ .Metadata.Description }}"> <input type="text" id="description" name="description" value="{{ .Metadata.Description }}">
<input type="text" id="isbn_10" name="isbn_10" value="{{ .Metadata.ISBN10 }}"> <input type="text" id="isbn_10" name="isbn_10" value="{{ .Metadata.ISBN10 }}">
<input type="text" id="isbn_13" name="isbn_13" value="{{ .Metadata.ISBN13 }}"> <input type="text" id="isbn_13" name="isbn_13" value="{{ .Metadata.ISBN13 }}">
<input type="text" id="cover_gbid" name="cover_gbid" value="{{ .Metadata.GBID }}"> <input type="text" id="cover_gbid" name="cover_gbid" value="{{ .Metadata.ID }}">
</div> </div>
</form> </form>
<div class="flex justify-end gap-4 m-4"> <div class="flex justify-end gap-4 m-4">

View File

@ -45,7 +45,7 @@
<div> <div>
<p class="text-gray-400">Time Read</p> <p class="text-gray-400">Time Read</p>
<p class="font-medium"> <p class="font-medium">
{{ $doc.TotalTimeMinutes }} Minutes {{ NiceSeconds $doc.TotalTimeSeconds }}
</p> </p>
</div> </div>
</div> </div>

View File

@ -1,5 +1,10 @@
package utils package utils
import (
"fmt"
"math"
)
type UTCOffset struct { type UTCOffset struct {
Name string Name string
Value string Value string
@ -49,3 +54,27 @@ var UTC_OFFSETS = []UTCOffset{
func GetUTCOffsets() []UTCOffset { func GetUTCOffsets() []UTCOffset {
return UTC_OFFSETS return UTC_OFFSETS
} }
func NiceSeconds(input int64) (result string) {
days := math.Floor(float64(input) / 60 / 60 / 24)
seconds := input % (60 * 60 * 24)
hours := math.Floor(float64(seconds) / 60 / 60)
seconds = input % (60 * 60)
minutes := math.Floor(float64(seconds) / 60)
seconds = input % 60
if days > 0 {
result += fmt.Sprintf("%dd ", int(days))
}
if hours > 0 {
result += fmt.Sprintf("%dh ", int(hours))
}
if minutes > 0 {
result += fmt.Sprintf("%dm ", int(minutes))
}
if seconds > 0 {
result += fmt.Sprintf("%ds", int(seconds))
}
return
}