Compare commits
4 Commits
0.0.1
...
bb837dd30e
| Author | SHA1 | Date | |
|---|---|---|---|
| bb837dd30e | |||
| e823a794cf | |||
| 3c6f3ae237 | |||
| ca1cce1ff1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
TODO.md
|
||||||
.DS_Store
|
.DS_Store
|
||||||
data/
|
data/
|
||||||
build/
|
build/
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ The service is now accessible at: `http://localhost:8585`. I recommend registeri
|
|||||||
| CONFIG_PATH | /config | Directory where to store SQLite's DB |
|
| CONFIG_PATH | /config | Directory where to store SQLite's DB |
|
||||||
| DATA_PATH | /data | Directory where to store the documents and cover metadata |
|
| DATA_PATH | /data | Directory where to store the documents and cover metadata |
|
||||||
| LISTEN_PORT | 8585 | Port the server listens at |
|
| LISTEN_PORT | 8585 | Port the server listens at |
|
||||||
|
| LOG_LEVEL | info | Set server log level |
|
||||||
| REGISTRATION_ENABLED | false | Whether to allow registration (applies to both WebApp & KOSync API) |
|
| REGISTRATION_ENABLED | false | Whether to allow registration (applies to both WebApp & KOSync API) |
|
||||||
| COOKIE_SESSION_KEY | <EMPTY> | Optional secret cookie session key (auto generated if not provided) |
|
| COOKIE_SESSION_KEY | <EMPTY> | Optional secret cookie session key (auto generated if not provided) |
|
||||||
| COOKIE_SECURE | true | Set Cookie `Secure` attribute (i.e. only works over HTTPS) |
|
| COOKIE_SECURE | true | Set Cookie `Secure` attribute (i.e. only works over HTTPS) |
|
||||||
|
|||||||
13
api/api.go
13
api/api.go
@@ -92,11 +92,16 @@ func (api *API) registerWebAppRoutes() {
|
|||||||
|
|
||||||
// Static Assets (Required @ Root)
|
// Static Assets (Required @ Root)
|
||||||
api.Router.GET("/manifest.json", api.webManifest)
|
api.Router.GET("/manifest.json", api.webManifest)
|
||||||
|
api.Router.GET("/favicon.ico", api.faviconIcon)
|
||||||
api.Router.GET("/sw.js", api.serviceWorker)
|
api.Router.GET("/sw.js", api.serviceWorker)
|
||||||
|
|
||||||
// Local / Offline Static Pages (No Template, No Auth)
|
// Local / Offline Static Pages (No Template, No Auth)
|
||||||
api.Router.GET("/local", api.localDocuments)
|
api.Router.GET("/local", api.localDocuments)
|
||||||
|
|
||||||
|
// Reader (Reader Page, Document Progress, Devices)
|
||||||
api.Router.GET("/reader", api.documentReader)
|
api.Router.GET("/reader", api.documentReader)
|
||||||
|
api.Router.GET("/reader/devices", api.authWebAppMiddleware, api.getDevices)
|
||||||
|
api.Router.GET("/reader/progress/:document", api.authWebAppMiddleware, api.getDocumentProgress)
|
||||||
|
|
||||||
// Web App
|
// Web App
|
||||||
api.Router.GET("/", api.authWebAppMiddleware, api.createAppResourcesRoute("home"))
|
api.Router.GET("/", api.authWebAppMiddleware, api.createAppResourcesRoute("home"))
|
||||||
@@ -105,7 +110,6 @@ func (api *API) registerWebAppRoutes() {
|
|||||||
api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document"))
|
api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document"))
|
||||||
api.Router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.getDocumentCover)
|
api.Router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.getDocumentCover)
|
||||||
api.Router.GET("/documents/:document/file", api.authWebAppMiddleware, api.downloadDocument)
|
api.Router.GET("/documents/:document/file", api.authWebAppMiddleware, api.downloadDocument)
|
||||||
api.Router.GET("/documents/:document/progress", api.authWebAppMiddleware, api.getDocumentProgress)
|
|
||||||
api.Router.GET("/login", api.createAppResourcesRoute("login"))
|
api.Router.GET("/login", api.createAppResourcesRoute("login"))
|
||||||
api.Router.GET("/logout", api.authWebAppMiddleware, api.authLogout)
|
api.Router.GET("/logout", api.authWebAppMiddleware, api.authLogout)
|
||||||
api.Router.GET("/register", api.createAppResourcesRoute("login", gin.H{"Register": true}))
|
api.Router.GET("/register", api.createAppResourcesRoute("login", gin.H{"Register": true}))
|
||||||
@@ -163,11 +167,12 @@ func (api *API) registerOPDSRoutes(apiGroup *gin.RouterGroup) {
|
|||||||
opdsGroup := apiGroup.Group("/opds")
|
opdsGroup := apiGroup.Group("/opds")
|
||||||
|
|
||||||
// OPDS Routes
|
// OPDS Routes
|
||||||
opdsGroup.GET("", api.authOPDSMiddleware, api.opdsDocuments)
|
opdsGroup.GET("", api.authOPDSMiddleware, api.opdsEntry)
|
||||||
opdsGroup.GET("/", api.authOPDSMiddleware, api.opdsDocuments)
|
opdsGroup.GET("/", api.authOPDSMiddleware, api.opdsEntry)
|
||||||
|
opdsGroup.GET("/search.xml", api.authOPDSMiddleware, api.opdsSearchDescription)
|
||||||
|
opdsGroup.GET("/documents", api.authOPDSMiddleware, api.opdsDocuments)
|
||||||
opdsGroup.GET("/documents/:document/cover", api.authOPDSMiddleware, api.getDocumentCover)
|
opdsGroup.GET("/documents/:document/cover", api.authOPDSMiddleware, api.getDocumentCover)
|
||||||
opdsGroup.GET("/documents/:document/file", api.authOPDSMiddleware, api.downloadDocument)
|
opdsGroup.GET("/documents/:document/file", api.authOPDSMiddleware, api.downloadDocument)
|
||||||
opdsGroup.GET("/search.xml", api.authOPDSMiddleware, api.opdsSearchDescription)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateToken(n int) ([]byte, error) {
|
func generateToken(n int) ([]byte, error) {
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ type queryParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type searchParams struct {
|
type searchParams struct {
|
||||||
Query *string `form:"query"`
|
Query *string `form:"query"`
|
||||||
BookType *string `form:"book_type"`
|
Source *search.Source `form:"source"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type requestDocumentUpload struct {
|
type requestDocumentUpload struct {
|
||||||
@@ -64,10 +64,10 @@ type requestSettingsEdit struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type requestDocumentAdd struct {
|
type requestDocumentAdd struct {
|
||||||
ID *string `form:"id"`
|
ID string `form:"id"`
|
||||||
Title *string `form:"title"`
|
Title *string `form:"title"`
|
||||||
Author *string `form:"author"`
|
Author *string `form:"author"`
|
||||||
BookType *string `form:"book_type"`
|
Source search.Source `form:"source"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) webManifest(c *gin.Context) {
|
func (api *API) webManifest(c *gin.Context) {
|
||||||
@@ -79,6 +79,10 @@ func (api *API) serviceWorker(c *gin.Context) {
|
|||||||
c.File("./assets/sw.js")
|
c.File("./assets/sw.js")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *API) faviconIcon(c *gin.Context) {
|
||||||
|
c.File("./assets/icons/favicon.ico")
|
||||||
|
}
|
||||||
|
|
||||||
func (api *API) localDocuments(c *gin.Context) {
|
func (api *API) localDocuments(c *gin.Context) {
|
||||||
c.File("./assets/local/index.html")
|
c.File("./assets/local/index.html")
|
||||||
}
|
}
|
||||||
@@ -109,10 +113,9 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
|
|||||||
}
|
}
|
||||||
templateVars["User"] = userID
|
templateVars["User"] = userID
|
||||||
|
|
||||||
// Potential URL Parameters
|
|
||||||
qParams := bindQueryParams(c)
|
|
||||||
|
|
||||||
if routeName == "documents" {
|
if routeName == "documents" {
|
||||||
|
qParams := bindQueryParams(c, 9)
|
||||||
|
|
||||||
var query *string
|
var query *string
|
||||||
if qParams.Search != nil && *qParams.Search != "" {
|
if qParams.Search != nil && *qParams.Search != "" {
|
||||||
search := "%" + *qParams.Search + "%"
|
search := "%" + *qParams.Search + "%"
|
||||||
@@ -177,6 +180,8 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
|
|||||||
templateVars["Data"] = document
|
templateVars["Data"] = document
|
||||||
templateVars["TotalTimeLeftSeconds"] = int64((100.0 - document.Percentage) * float64(document.SecondsPerPercent))
|
templateVars["TotalTimeLeftSeconds"] = int64((100.0 - document.Percentage) * float64(document.SecondsPerPercent))
|
||||||
} else if routeName == "activity" {
|
} else if routeName == "activity" {
|
||||||
|
qParams := bindQueryParams(c, 15)
|
||||||
|
|
||||||
activityFilter := database.GetActivityParams{
|
activityFilter := database.GetActivityParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
||||||
@@ -240,25 +245,18 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
|
|||||||
c.BindQuery(&sParams)
|
c.BindQuery(&sParams)
|
||||||
|
|
||||||
// Only Handle Query
|
// Only Handle Query
|
||||||
if sParams.BookType != nil && !slices.Contains([]string{"NON_FICTION", "FICTION"}, *sParams.BookType) {
|
if sParams.Query != nil && sParams.Source != nil {
|
||||||
templateVars["SearchErrorMessage"] = "Invalid Book Type"
|
|
||||||
} else if sParams.Query != nil && *sParams.Query == "" {
|
|
||||||
templateVars["SearchErrorMessage"] = "Invalid Query"
|
|
||||||
} else if sParams.BookType != nil && sParams.Query != nil {
|
|
||||||
var bType search.BookType = search.BOOK_FICTION
|
|
||||||
if *sParams.BookType == "NON_FICTION" {
|
|
||||||
bType = search.BOOK_NON_FICTION
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
searchResults, err := search.SearchBook(*sParams.Query, bType)
|
searchResults, err := search.SearchBook(*sParams.Query, *sParams.Source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err))
|
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("Search Error: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
templateVars["Data"] = searchResults
|
templateVars["Data"] = searchResults
|
||||||
templateVars["BookType"] = *sParams.BookType
|
templateVars["Source"] = *sParams.Source
|
||||||
|
} else if sParams.Query != nil || sParams.Source != nil {
|
||||||
|
templateVars["SearchErrorMessage"] = "Invalid Query"
|
||||||
}
|
}
|
||||||
} else if routeName == "login" {
|
} else if routeName == "login" {
|
||||||
templateVars["RegistrationEnabled"] = api.Config.RegistrationEnabled
|
templateVars["RegistrationEnabled"] = api.Config.RegistrationEnabled
|
||||||
@@ -400,6 +398,20 @@ func (api *API) getDocumentProgress(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *API) getDevices(c *gin.Context) {
|
||||||
|
rUser, _ := c.Get("AuthorizedUser")
|
||||||
|
|
||||||
|
devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, rUser.(string))
|
||||||
|
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
log.Error("[getDevices] GetDevices DB Error:", err)
|
||||||
|
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, devices)
|
||||||
|
}
|
||||||
|
|
||||||
func (api *API) uploadNewDocument(c *gin.Context) {
|
func (api *API) uploadNewDocument(c *gin.Context) {
|
||||||
var rDocUpload requestDocumentUpload
|
var rDocUpload requestDocumentUpload
|
||||||
if err := c.ShouldBind(&rDocUpload); err != nil {
|
if err := c.ShouldBind(&rDocUpload); err != nil {
|
||||||
@@ -762,23 +774,8 @@ func (api *API) saveNewDocument(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate Form Exists
|
|
||||||
if rDocAdd.ID == nil ||
|
|
||||||
rDocAdd.BookType == nil ||
|
|
||||||
rDocAdd.Title == nil ||
|
|
||||||
rDocAdd.Author == nil {
|
|
||||||
log.Error("[saveNewDocument] Missing Form Values")
|
|
||||||
errorPage(c, http.StatusBadRequest, "Invalid or missing form values.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var bType search.BookType = search.BOOK_FICTION
|
|
||||||
if *rDocAdd.BookType == "NON_FICTION" {
|
|
||||||
bType = search.BOOK_NON_FICTION
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save Book
|
// Save Book
|
||||||
tempFilePath, err := search.SaveBook(*rDocAdd.ID, bType)
|
tempFilePath, err := search.SaveBook(rDocAdd.ID, rDocAdd.Source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("[saveNewDocument] Temp File Error: ", err)
|
log.Warn("[saveNewDocument] Temp File Error: ", err)
|
||||||
errorPage(c, http.StatusInternalServerError, "Unable to save file.")
|
errorPage(c, http.StatusInternalServerError, "Unable to save file.")
|
||||||
@@ -999,13 +996,12 @@ func (api *API) getDocumentsWordCount(documents []database.GetDocumentsWithStats
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func bindQueryParams(c *gin.Context) queryParams {
|
func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams {
|
||||||
var qParams queryParams
|
var qParams queryParams
|
||||||
c.BindQuery(&qParams)
|
c.BindQuery(&qParams)
|
||||||
|
|
||||||
if qParams.Limit == nil {
|
if qParams.Limit == nil {
|
||||||
var defaultValue int64 = 9
|
qParams.Limit = &defaultLimit
|
||||||
qParams.Limit = &defaultValue
|
|
||||||
} else if *qParams.Limit < 0 {
|
} else if *qParams.Limit < 0 {
|
||||||
var zeroValue int64 = 0
|
var zeroValue int64 = 0
|
||||||
qParams.Limit = &zeroValue
|
qParams.Limit = &zeroValue
|
||||||
|
|||||||
@@ -26,18 +26,60 @@ var mimeMapping map[string]string = map[string]string{
|
|||||||
"lit": "application/x-ms-reader",
|
"lit": "application/x-ms-reader",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *API) opdsEntry(c *gin.Context) {
|
||||||
|
// Build & Return XML
|
||||||
|
mainFeed := &opds.Feed{
|
||||||
|
Title: "AnthoLume OPDS Server",
|
||||||
|
Updated: time.Now().UTC(),
|
||||||
|
Links: []opds.Link{
|
||||||
|
{
|
||||||
|
Title: "Search AnthoLume",
|
||||||
|
Rel: "search",
|
||||||
|
TypeLink: "application/opensearchdescription+xml",
|
||||||
|
Href: "/api/opds/search.xml",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
Entries: []opds.Entry{
|
||||||
|
{
|
||||||
|
Title: "AnthoLume - All Documents",
|
||||||
|
Content: &opds.Content{
|
||||||
|
Content: "AnthoLume - All Documents",
|
||||||
|
ContentType: "text",
|
||||||
|
},
|
||||||
|
Links: []opds.Link{
|
||||||
|
{
|
||||||
|
Href: "/api/opds/documents",
|
||||||
|
TypeLink: "application/atom+xml;type=feed;profile=opds-catalog",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
c.XML(http.StatusOK, mainFeed)
|
||||||
|
}
|
||||||
|
|
||||||
func (api *API) opdsDocuments(c *gin.Context) {
|
func (api *API) opdsDocuments(c *gin.Context) {
|
||||||
var userID string
|
var userID string
|
||||||
if rUser, _ := c.Get("AuthorizedUser"); rUser != nil {
|
if rUser, _ := c.Get("AuthorizedUser"); rUser != nil {
|
||||||
userID = rUser.(string)
|
userID = rUser.(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Potential URL Parameters
|
// Potential URL Parameters (Default Pagination - 100)
|
||||||
qParams := bindQueryParams(c)
|
qParams := bindQueryParams(c, 100)
|
||||||
|
|
||||||
|
// Possible Query
|
||||||
|
var query *string
|
||||||
|
if qParams.Search != nil && *qParams.Search != "" {
|
||||||
|
search := "%" + *qParams.Search + "%"
|
||||||
|
query = &search
|
||||||
|
}
|
||||||
|
|
||||||
// Get Documents
|
// Get Documents
|
||||||
documents, err := api.DB.Queries.GetDocumentsWithStats(api.DB.Ctx, database.GetDocumentsWithStatsParams{
|
documents, err := api.DB.Queries.GetDocumentsWithStats(api.DB.Ctx, database.GetDocumentsWithStatsParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
Query: query,
|
||||||
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
Offset: (*qParams.Page - 1) * *qParams.Limit,
|
||||||
Limit: *qParams.Limit,
|
Limit: *qParams.Limit,
|
||||||
})
|
})
|
||||||
@@ -71,7 +113,7 @@ func (api *API) opdsDocuments(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
item := opds.Entry{
|
item := opds.Entry{
|
||||||
Title: fmt.Sprintf("[%3d%%] %s", int(doc.Percentage), title),
|
Title: title,
|
||||||
Author: []opds.Author{
|
Author: []opds.Author{
|
||||||
{
|
{
|
||||||
Name: author,
|
Name: author,
|
||||||
@@ -84,12 +126,12 @@ func (api *API) opdsDocuments(c *gin.Context) {
|
|||||||
Links: []opds.Link{
|
Links: []opds.Link{
|
||||||
{
|
{
|
||||||
Rel: "http://opds-spec.org/acquisition",
|
Rel: "http://opds-spec.org/acquisition",
|
||||||
Href: fmt.Sprintf("./documents/%s/file", doc.ID),
|
Href: fmt.Sprintf("/api/opds/documents/%s/file", doc.ID),
|
||||||
TypeLink: mimeMapping[fileType],
|
TypeLink: mimeMapping[fileType],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Rel: "http://opds-spec.org/image",
|
Rel: "http://opds-spec.org/image",
|
||||||
Href: fmt.Sprintf("./documents/%s/cover", doc.ID),
|
Href: fmt.Sprintf("/api/opds/documents/%s/cover", doc.ID),
|
||||||
TypeLink: "image/jpeg",
|
TypeLink: "image/jpeg",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -99,19 +141,15 @@ func (api *API) opdsDocuments(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
feedTitle := "All Documents"
|
||||||
|
if query != nil {
|
||||||
|
feedTitle = "Search Results"
|
||||||
|
}
|
||||||
|
|
||||||
// Build & Return XML
|
// Build & Return XML
|
||||||
searchFeed := &opds.Feed{
|
searchFeed := &opds.Feed{
|
||||||
Title: "All Documents",
|
Title: feedTitle,
|
||||||
Updated: time.Now().UTC(),
|
Updated: time.Now().UTC(),
|
||||||
// TODO
|
|
||||||
// Links: []opds.Link{
|
|
||||||
// {
|
|
||||||
// Title: "Search AnthoLume",
|
|
||||||
// Rel: "search",
|
|
||||||
// TypeLink: "application/opensearchdescription+xml",
|
|
||||||
// Href: "search.xml",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
Entries: allEntries,
|
Entries: allEntries,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +160,7 @@ func (api *API) opdsSearchDescription(c *gin.Context) {
|
|||||||
rawXML := `<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
rawXML := `<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||||
<ShortName>Search AnthoLume</ShortName>
|
<ShortName>Search AnthoLume</ShortName>
|
||||||
<Description>Search AnthoLume</Description>
|
<Description>Search AnthoLume</Description>
|
||||||
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition" template="./search?query={searchTerms}"/>
|
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition" template="/api/opds/documents?search={searchTerms}"/>
|
||||||
</OpenSearchDescription>`
|
</OpenSearchDescription>`
|
||||||
c.Data(http.StatusOK, "application/xml", []byte(rawXML))
|
c.Data(http.StatusOK, "application/xml", []byte(rawXML))
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
assets/icons/favicon.ico
Normal file
BIN
assets/icons/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
1
assets/lib/platform.min.js
vendored
1
assets/lib/platform.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -31,7 +31,6 @@
|
|||||||
<script src="/assets/lib/jszip.min.js"></script>
|
<script src="/assets/lib/jszip.min.js"></script>
|
||||||
<script src="/assets/lib/epub.min.js"></script>
|
<script src="/assets/lib/epub.min.js"></script>
|
||||||
<script src="/assets/lib/idb-keyval.min.js"></script>
|
<script src="/assets/lib/idb-keyval.min.js"></script>
|
||||||
<script src="/assets/lib/sw-helper.min.js"></script>
|
|
||||||
|
|
||||||
<!-- Local -->
|
<!-- Local -->
|
||||||
<script src="/assets/common.js"></script>
|
<script src="/assets/common.js"></script>
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
<link rel="stylesheet" href="/assets/style.css" />
|
<link rel="stylesheet" href="/assets/style.css" />
|
||||||
|
|
||||||
<!-- Libraries -->
|
<!-- Libraries -->
|
||||||
<script src="/assets/lib/platform.min.js"></script>
|
|
||||||
<script src="/assets/lib/jszip.min.js"></script>
|
<script src="/assets/lib/jszip.min.js"></script>
|
||||||
<script src="/assets/lib/epub.min.js"></script>
|
<script src="/assets/lib/epub.min.js"></script>
|
||||||
<script src="/assets/lib/no-sleep.min.js"></script>
|
<script src="/assets/lib/no-sleep.min.js"></script>
|
||||||
@@ -71,6 +70,10 @@
|
|||||||
#top-bar:not(.top-0) {
|
#top-bar:not(.top-0) {
|
||||||
top: calc((8em + env(safe-area-inset-top)) * -1);
|
top: calc((8em + env(safe-area-inset-top)) * -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
select:invalid {
|
||||||
|
color: gray;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100 dark:bg-gray-800">
|
<body class="bg-gray-100 dark:bg-gray-800">
|
||||||
@@ -260,5 +263,120 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="viewer" class="w-full h-full"></div>
|
<div id="viewer" class="w-full h-full"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Device Selector -->
|
||||||
|
<div
|
||||||
|
id="device-selector"
|
||||||
|
class="hidden absolute top-0 left-0 w-full h-full z-50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="relative flex flex-col gap-4 p-4 max-h-[95%] w-5/6 md:w-1/2 bg-white dark:bg-gray-800 overflow-scroll -translate-x-2/4 -translate-y-2/4 top-1/2 left-1/2 overflow-hidden shadow rounded"
|
||||||
|
>
|
||||||
|
<div class="text-center flex flex-col gap-2">
|
||||||
|
<h3 class="text-lg font-bold leading-6 dark:text-gray-300">
|
||||||
|
Select Device
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-500 text-center">
|
||||||
|
This device appears to be new! Please either assume an existing
|
||||||
|
device, or create a new one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<div class="flex gap-4 flex-col">
|
||||||
|
<div class="flex relative min-w-[12em]">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M5.65517 2.22732C5.2225 2.34037 4.9438 2.50021 4.72718 2.71244C4.42179 3.01165 4.22268 3.43172 4.11382 4.225C4.00176 5.04159 4 6.12387 4 7.67568V16.2442C4.38867 15.9781 4.82674 15.7756 5.29899 15.6517C5.41296 15.6217 5.53103 15.5983 5.65517 15.5799V2.22732Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7.31034 15.5135C7.32206 15.5135 7.33382 15.5135 7.34563 15.5135L20 15.5135V7.67568C20 6.12387 19.9982 5.04159 19.8862 4.22499C19.7773 3.43172 19.5782 3.01165 19.2728 2.71244C18.9674 2.41324 18.5387 2.21816 17.729 2.11151C16.8955 2.00172 15.7908 2 14.2069 2H9.7931C8.79138 2 7.98133 2.00069 7.31034 2.02897V15.5135Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7.47341 17.1351C6.39395 17.1351 6.01657 17.1421 5.72738 17.218C4.93365 17.4264 4.30088 18.0044 4.02952 18.7558C4.0463 19.1382 4.07259 19.4746 4.11382 19.775C4.22268 20.5683 4.42179 20.9884 4.72718 21.2876C5.03258 21.5868 5.46135 21.7818 6.27103 21.8885C7.10452 21.9983 8.2092 22 9.7931 22H14.2069C15.7908 22 16.8955 21.9983 17.729 21.8885C18.5387 21.7818 18.9674 21.5868 19.2728 21.2876C19.5782 20.9884 19.7773 20.5683 19.8862 19.775C19.9776 19.1088 19.9956 18.2657 19.9991 17.1351H7.47341Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||||
|
id="source"
|
||||||
|
name="source"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled selected hidden>
|
||||||
|
Select Existing Device
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"
|
||||||
|
>
|
||||||
|
<span class="w-full">Assume Device</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
|
||||||
|
>
|
||||||
|
<div class="flex gap-4 flex-col">
|
||||||
|
<div class="flex flex-col w-full grow">
|
||||||
|
<div class="flex relative">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M5.65517 2.22732C5.2225 2.34037 4.9438 2.50021 4.72718 2.71244C4.42179 3.01165 4.22268 3.43172 4.11382 4.225C4.00176 5.04159 4 6.12387 4 7.67568V16.2442C4.38867 15.9781 4.82674 15.7756 5.29899 15.6517C5.41296 15.6217 5.53103 15.5983 5.65517 15.5799V2.22732Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7.31034 15.5135C7.32206 15.5135 7.33382 15.5135 7.34563 15.5135L20 15.5135V7.67568C20 6.12387 19.9982 5.04159 19.8862 4.22499C19.7773 3.43172 19.5782 3.01165 19.2728 2.71244C18.9674 2.41324 18.5387 2.21816 17.729 2.11151C16.8955 2.00172 15.7908 2 14.2069 2H9.7931C8.79138 2 7.98133 2.00069 7.31034 2.02897V15.5135Z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7.47341 17.1351C6.39395 17.1351 6.01657 17.1421 5.72738 17.218C4.93365 17.4264 4.30088 18.0044 4.02952 18.7558C4.0463 19.1382 4.07259 19.4746 4.11382 19.775C4.22268 20.5683 4.42179 20.9884 4.72718 21.2876C5.03258 21.5868 5.46135 21.7818 6.27103 21.8885C7.10452 21.9983 8.2092 22 9.7931 22H14.2069C15.7908 22 16.8955 21.9983 17.729 21.8885C18.5387 21.7818 18.9674 21.5868 19.2728 21.2876C19.5782 20.9884 19.7773 20.5683 19.8862 19.775C19.9776 19.1088 19.9956 18.2657 19.9991 17.1351H7.47341Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||||
|
placeholder="New Device Name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"
|
||||||
|
>
|
||||||
|
<span class="w-full">Create Device</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ async function initReader() {
|
|||||||
|
|
||||||
if (documentType == "REMOTE") {
|
if (documentType == "REMOTE") {
|
||||||
// Get Server / Cached Document
|
// Get Server / Cached Document
|
||||||
let progressResp = await fetch("/documents/" + documentID + "/progress");
|
let progressResp = await fetch("/reader/progress/" + documentID);
|
||||||
documentData = await progressResp.json();
|
documentData = await progressResp.json();
|
||||||
|
|
||||||
// Update With Local Cache
|
// Update With Local Cache
|
||||||
@@ -145,14 +145,60 @@ class EBookReader {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.readerSettings.deviceName =
|
// Device Already Set
|
||||||
this.readerSettings.deviceName ||
|
if (this.readerSettings.deviceID) return;
|
||||||
platform.os.toString() + " - " + platform.name;
|
|
||||||
|
|
||||||
this.readerSettings.deviceID = this.readerSettings.deviceID || randomID();
|
// Get Elements
|
||||||
|
let devicePopup = document.querySelector("#device-selector");
|
||||||
|
let devSelector = devicePopup.querySelector("select");
|
||||||
|
let devInput = devicePopup.querySelector("input");
|
||||||
|
let [assumeButton, createButton] = devicePopup.querySelectorAll("button");
|
||||||
|
|
||||||
// Save Settings (Device ID)
|
// Set Visible
|
||||||
this.saveSettings();
|
devicePopup.classList.remove("hidden");
|
||||||
|
|
||||||
|
// Add Devices
|
||||||
|
fetch("/reader/devices").then(async (r) => {
|
||||||
|
let data = await r.json();
|
||||||
|
|
||||||
|
data.forEach((item) => {
|
||||||
|
let optionEl = document.createElement("option");
|
||||||
|
optionEl.value = item.id;
|
||||||
|
optionEl.textContent = item.device_name;
|
||||||
|
devSelector.appendChild(optionEl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
assumeButton.addEventListener("click", () => {
|
||||||
|
let deviceID = devSelector.value;
|
||||||
|
|
||||||
|
if (deviceID == "") {
|
||||||
|
// TODO - Error Message
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selectedOption = devSelector.children[devSelector.selectedIndex];
|
||||||
|
let deviceName = selectedOption.textContent;
|
||||||
|
|
||||||
|
this.readerSettings.deviceID = deviceID;
|
||||||
|
this.readerSettings.deviceName = deviceName;
|
||||||
|
this.saveSettings();
|
||||||
|
devicePopup.classList.add("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
createButton.addEventListener("click", () => {
|
||||||
|
let deviceName = devInput.value.trim();
|
||||||
|
|
||||||
|
if (deviceName == "") {
|
||||||
|
// TODO - Error Message
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.readerSettings.deviceID = randomID();
|
||||||
|
this.readerSettings.deviceName = deviceName;
|
||||||
|
this.saveSettings();
|
||||||
|
devicePopup.classList.add("hidden");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
30
assets/sw.js
30
assets/sw.js
@@ -44,7 +44,7 @@ const ROUTES = [
|
|||||||
type: CACHE_UPDATE_ASYNC,
|
type: CACHE_UPDATE_ASYNC,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
route: /^\/documents\/[a-zA-Z0-9]{32}\/progress$/,
|
route: /^\/reader\/progress\/[a-zA-Z0-9]{32}$/,
|
||||||
type: CACHE_UPDATE_SYNC,
|
type: CACHE_UPDATE_SYNC,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -74,7 +74,6 @@ const PRECACHE_ASSETS = [
|
|||||||
"/assets/common.js",
|
"/assets/common.js",
|
||||||
|
|
||||||
// Library Assets
|
// Library Assets
|
||||||
"/assets/lib/platform.min.js",
|
|
||||||
"/assets/lib/jszip.min.js",
|
"/assets/lib/jszip.min.js",
|
||||||
"/assets/lib/epub.min.js",
|
"/assets/lib/epub.min.js",
|
||||||
"/assets/lib/no-sleep.min.js",
|
"/assets/lib/no-sleep.min.js",
|
||||||
@@ -120,7 +119,9 @@ async function handleFetch(event) {
|
|||||||
|
|
||||||
// Find Directive
|
// Find Directive
|
||||||
const directive = ROUTES.find(
|
const directive = ROUTES.find(
|
||||||
(item) => url.match(item.route) || url == item.route
|
(item) =>
|
||||||
|
(item.route instanceof RegExp && url.match(item.route)) ||
|
||||||
|
url == item.route
|
||||||
) || { type: CACHE_NEVER };
|
) || { type: CACHE_NEVER };
|
||||||
|
|
||||||
// Get Fallback
|
// Get Fallback
|
||||||
@@ -170,12 +171,22 @@ function handleMessage(event) {
|
|||||||
caches.open(SW_CACHE_NAME).then(async (cache) => {
|
caches.open(SW_CACHE_NAME).then(async (cache) => {
|
||||||
let allKeys = await cache.keys();
|
let allKeys = await cache.keys();
|
||||||
|
|
||||||
|
// Get Cached Resources
|
||||||
let docResources = allKeys
|
let docResources = allKeys
|
||||||
.map((item) => new URL(item.url).pathname)
|
.map((item) => new URL(item.url).pathname)
|
||||||
.filter((item) => item.startsWith("/documents/"));
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
item.startsWith("/documents/") ||
|
||||||
|
item.startsWith("/reader/progress/")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derive Unique IDs
|
||||||
let documentIDs = Array.from(
|
let documentIDs = Array.from(
|
||||||
new Set(docResources.map((item) => item.split("/")[2]))
|
new Set(
|
||||||
|
docResources
|
||||||
|
.filter((item) => item.startsWith("/documents/"))
|
||||||
|
.map((item) => item.split("/")[2])
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -188,10 +199,10 @@ function handleMessage(event) {
|
|||||||
.filter(
|
.filter(
|
||||||
(id) =>
|
(id) =>
|
||||||
docResources.includes("/documents/" + id + "/file") &&
|
docResources.includes("/documents/" + id + "/file") &&
|
||||||
docResources.includes("/documents/" + id + "/progress")
|
docResources.includes("/reader/progress/" + id)
|
||||||
)
|
)
|
||||||
.map(async (id) => {
|
.map(async (id) => {
|
||||||
let url = "/documents/" + id + "/progress";
|
let url = "/reader/progress/" + id;
|
||||||
let currentCache = await caches.match(url);
|
let currentCache = await caches.match(url);
|
||||||
let resp = await updateCache(url).catch((e) => currentCache);
|
let resp = await updateCache(url).catch((e) => currentCache);
|
||||||
return resp.json();
|
return resp.json();
|
||||||
@@ -201,13 +212,12 @@ function handleMessage(event) {
|
|||||||
event.source.postMessage({ id, data: cachedDocuments });
|
event.source.postMessage({ id, data: cachedDocuments });
|
||||||
});
|
});
|
||||||
} else if (data.type === DEL_SW_CACHE) {
|
} else if (data.type === DEL_SW_CACHE) {
|
||||||
let basePath = "/documents/" + data.id;
|
|
||||||
caches
|
caches
|
||||||
.open(SW_CACHE_NAME)
|
.open(SW_CACHE_NAME)
|
||||||
.then((cache) =>
|
.then((cache) =>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
cache.delete(basePath + "/file"),
|
cache.delete("/documents/" + data.id + "/file"),
|
||||||
cache.delete(basePath + "/progress"),
|
cache.delete("/reader/progress/" + data.id),
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
.then(() => event.source.postMessage({ id, data: "SUCCESS" }))
|
.then(() => event.source.postMessage({ id, data: "SUCCESS" }))
|
||||||
|
|||||||
@@ -493,7 +493,7 @@ function SyncNinja:userLogin(username, password, menu)
|
|||||||
self.settings.password = userkey
|
self.settings.password = userkey
|
||||||
if menu then menu:updateItems() end
|
if menu then menu:updateItems() end
|
||||||
UIManager:show(InfoMessage:new{
|
UIManager:show(InfoMessage:new{
|
||||||
text = _("Logged in to KOReader server.")
|
text = _("Logged in to AnthoLume server.")
|
||||||
})
|
})
|
||||||
|
|
||||||
self:schedulePeriodicPush(0)
|
self:schedulePeriodicPush(0)
|
||||||
@@ -532,7 +532,7 @@ function SyncNinja:userRegister(username, password, menu)
|
|||||||
self.settings.password = userkey
|
self.settings.password = userkey
|
||||||
if menu then menu:updateItems() end
|
if menu then menu:updateItems() end
|
||||||
UIManager:show(InfoMessage:new{
|
UIManager:show(InfoMessage:new{
|
||||||
text = _("Registered to KOReader server.")
|
text = _("Registered to AnthoLume server.")
|
||||||
})
|
})
|
||||||
|
|
||||||
self:schedulePeriodicPush(0)
|
self:schedulePeriodicPush(0)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package config
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -22,6 +24,7 @@ type Config struct {
|
|||||||
RegistrationEnabled bool
|
RegistrationEnabled bool
|
||||||
SearchEnabled bool
|
SearchEnabled bool
|
||||||
DemoMode bool
|
DemoMode bool
|
||||||
|
LogLevel string
|
||||||
|
|
||||||
// Cookie Settings
|
// Cookie Settings
|
||||||
CookieSessionKey string
|
CookieSessionKey string
|
||||||
@@ -30,7 +33,7 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
return &Config{
|
c := &Config{
|
||||||
Version: "0.0.1",
|
Version: "0.0.1",
|
||||||
DBType: trimLowerString(getEnv("DATABASE_TYPE", "SQLite")),
|
DBType: trimLowerString(getEnv("DATABASE_TYPE", "SQLite")),
|
||||||
DBName: trimLowerString(getEnv("DATABASE_NAME", "antholume")),
|
DBName: trimLowerString(getEnv("DATABASE_NAME", "antholume")),
|
||||||
@@ -41,9 +44,19 @@ func Load() *Config {
|
|||||||
DemoMode: trimLowerString(getEnv("DEMO_MODE", "false")) == "true",
|
DemoMode: trimLowerString(getEnv("DEMO_MODE", "false")) == "true",
|
||||||
SearchEnabled: trimLowerString(getEnv("SEARCH_ENABLED", "false")) == "true",
|
SearchEnabled: trimLowerString(getEnv("SEARCH_ENABLED", "false")) == "true",
|
||||||
CookieSessionKey: trimLowerString(getEnv("COOKIE_SESSION_KEY", "")),
|
CookieSessionKey: trimLowerString(getEnv("COOKIE_SESSION_KEY", "")),
|
||||||
|
LogLevel: trimLowerString(getEnv("LOG_LEVEL", "info")),
|
||||||
CookieSecure: trimLowerString(getEnv("COOKIE_SECURE", "true")) == "true",
|
CookieSecure: trimLowerString(getEnv("COOKIE_SECURE", "true")) == "true",
|
||||||
CookieHTTPOnly: trimLowerString(getEnv("COOKIE_HTTP_ONLY", "true")) == "true",
|
CookieHTTPOnly: trimLowerString(getEnv("COOKIE_HTTP_ONLY", "true")) == "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log Level
|
||||||
|
ll, err := log.ParseLevel(c.LogLevel)
|
||||||
|
if err != nil {
|
||||||
|
ll = log.InfoLevel
|
||||||
|
}
|
||||||
|
log.SetLevel(ll)
|
||||||
|
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnv(key, fallback string) string {
|
func getEnv(key, fallback string) string {
|
||||||
|
|||||||
@@ -40,9 +40,12 @@ WHERE id = $id;
|
|||||||
WITH filtered_activity AS (
|
WITH filtered_activity AS (
|
||||||
SELECT
|
SELECT
|
||||||
document_id,
|
document_id,
|
||||||
|
device_id,
|
||||||
user_id,
|
user_id,
|
||||||
start_time,
|
start_time,
|
||||||
duration,
|
duration,
|
||||||
|
ROUND(CAST(start_percentage AS REAL) * 100, 2) AS start_percentage,
|
||||||
|
ROUND(CAST(end_percentage AS REAL) * 100, 2) AS end_percentage,
|
||||||
ROUND(CAST(end_percentage - start_percentage AS REAL) * 100, 2) AS read_percentage
|
ROUND(CAST(end_percentage - start_percentage AS REAL) * 100, 2) AS read_percentage
|
||||||
FROM activity
|
FROM activity
|
||||||
WHERE
|
WHERE
|
||||||
@@ -60,10 +63,13 @@ WITH filtered_activity AS (
|
|||||||
|
|
||||||
SELECT
|
SELECT
|
||||||
document_id,
|
document_id,
|
||||||
|
device_id,
|
||||||
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', activity.start_time, users.time_offset) AS TEXT) AS start_time,
|
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', activity.start_time, users.time_offset) AS TEXT) AS start_time,
|
||||||
title,
|
title,
|
||||||
author,
|
author,
|
||||||
duration,
|
duration,
|
||||||
|
start_percentage,
|
||||||
|
end_percentage,
|
||||||
read_percentage
|
read_percentage
|
||||||
FROM filtered_activity AS activity
|
FROM filtered_activity AS activity
|
||||||
LEFT JOIN documents ON documents.id = activity.document_id
|
LEFT JOIN documents ON documents.id = activity.document_id
|
||||||
@@ -128,6 +134,7 @@ WHERE id = $device_id LIMIT 1;
|
|||||||
|
|
||||||
-- name: GetDevices :many
|
-- name: GetDevices :many
|
||||||
SELECT
|
SELECT
|
||||||
|
devices.id,
|
||||||
devices.device_name,
|
devices.device_name,
|
||||||
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.created_at, users.time_offset) AS TEXT) AS created_at,
|
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.created_at, users.time_offset) AS TEXT) AS created_at,
|
||||||
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.last_synced, users.time_offset) AS TEXT) AS last_synced
|
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.last_synced, users.time_offset) AS TEXT) AS last_synced
|
||||||
|
|||||||
@@ -150,9 +150,12 @@ const getActivity = `-- name: GetActivity :many
|
|||||||
WITH filtered_activity AS (
|
WITH filtered_activity AS (
|
||||||
SELECT
|
SELECT
|
||||||
document_id,
|
document_id,
|
||||||
|
device_id,
|
||||||
user_id,
|
user_id,
|
||||||
start_time,
|
start_time,
|
||||||
duration,
|
duration,
|
||||||
|
ROUND(CAST(start_percentage AS REAL) * 100, 2) AS start_percentage,
|
||||||
|
ROUND(CAST(end_percentage AS REAL) * 100, 2) AS end_percentage,
|
||||||
ROUND(CAST(end_percentage - start_percentage AS REAL) * 100, 2) AS read_percentage
|
ROUND(CAST(end_percentage - start_percentage AS REAL) * 100, 2) AS read_percentage
|
||||||
FROM activity
|
FROM activity
|
||||||
WHERE
|
WHERE
|
||||||
@@ -170,10 +173,13 @@ WITH filtered_activity AS (
|
|||||||
|
|
||||||
SELECT
|
SELECT
|
||||||
document_id,
|
document_id,
|
||||||
|
device_id,
|
||||||
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', activity.start_time, users.time_offset) AS TEXT) AS start_time,
|
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', activity.start_time, users.time_offset) AS TEXT) AS start_time,
|
||||||
title,
|
title,
|
||||||
author,
|
author,
|
||||||
duration,
|
duration,
|
||||||
|
start_percentage,
|
||||||
|
end_percentage,
|
||||||
read_percentage
|
read_percentage
|
||||||
FROM filtered_activity AS activity
|
FROM filtered_activity AS activity
|
||||||
LEFT JOIN documents ON documents.id = activity.document_id
|
LEFT JOIN documents ON documents.id = activity.document_id
|
||||||
@@ -189,12 +195,15 @@ type GetActivityParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GetActivityRow struct {
|
type GetActivityRow struct {
|
||||||
DocumentID string `json:"document_id"`
|
DocumentID string `json:"document_id"`
|
||||||
StartTime string `json:"start_time"`
|
DeviceID string `json:"device_id"`
|
||||||
Title *string `json:"title"`
|
StartTime string `json:"start_time"`
|
||||||
Author *string `json:"author"`
|
Title *string `json:"title"`
|
||||||
Duration int64 `json:"duration"`
|
Author *string `json:"author"`
|
||||||
ReadPercentage float64 `json:"read_percentage"`
|
Duration int64 `json:"duration"`
|
||||||
|
StartPercentage float64 `json:"start_percentage"`
|
||||||
|
EndPercentage float64 `json:"end_percentage"`
|
||||||
|
ReadPercentage float64 `json:"read_percentage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]GetActivityRow, error) {
|
func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]GetActivityRow, error) {
|
||||||
@@ -214,10 +223,13 @@ func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]Get
|
|||||||
var i GetActivityRow
|
var i GetActivityRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.DocumentID,
|
&i.DocumentID,
|
||||||
|
&i.DeviceID,
|
||||||
&i.StartTime,
|
&i.StartTime,
|
||||||
&i.Title,
|
&i.Title,
|
||||||
&i.Author,
|
&i.Author,
|
||||||
&i.Duration,
|
&i.Duration,
|
||||||
|
&i.StartPercentage,
|
||||||
|
&i.EndPercentage,
|
||||||
&i.ReadPercentage,
|
&i.ReadPercentage,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -390,6 +402,7 @@ func (q *Queries) GetDevice(ctx context.Context, deviceID string) (Device, error
|
|||||||
|
|
||||||
const getDevices = `-- name: GetDevices :many
|
const getDevices = `-- name: GetDevices :many
|
||||||
SELECT
|
SELECT
|
||||||
|
devices.id,
|
||||||
devices.device_name,
|
devices.device_name,
|
||||||
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.created_at, users.time_offset) AS TEXT) AS created_at,
|
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.created_at, users.time_offset) AS TEXT) AS created_at,
|
||||||
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.last_synced, users.time_offset) AS TEXT) AS last_synced
|
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.last_synced, users.time_offset) AS TEXT) AS last_synced
|
||||||
@@ -400,6 +413,7 @@ ORDER BY devices.last_synced DESC
|
|||||||
`
|
`
|
||||||
|
|
||||||
type GetDevicesRow struct {
|
type GetDevicesRow struct {
|
||||||
|
ID string `json:"id"`
|
||||||
DeviceName string `json:"device_name"`
|
DeviceName string `json:"device_name"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
LastSynced string `json:"last_synced"`
|
LastSynced string `json:"last_synced"`
|
||||||
@@ -414,7 +428,12 @@ func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRo
|
|||||||
var items []GetDevicesRow
|
var items []GetDevicesRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i GetDevicesRow
|
var i GetDevicesRow
|
||||||
if err := rows.Scan(&i.DeviceName, &i.CreatedAt, &i.LastSynced); err != nil {
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.DeviceName,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.LastSynced,
|
||||||
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
|
|||||||
@@ -4,13 +4,10 @@ This folder consists of various notes / files that I want to save and may come b
|
|||||||
|
|
||||||
# Ideas / To Do
|
# Ideas / To Do
|
||||||
|
|
||||||
- Rename!
|
|
||||||
- Google Fonts -> SW Cache and/or Local
|
- Google Fonts -> SW Cache and/or Local
|
||||||
- Search Documents
|
|
||||||
- Title, Author, Description
|
|
||||||
- Change Device Name / Assume Device
|
- Change Device Name / Assume Device
|
||||||
- Hide Document per User (Another Table?)
|
- Hide Document per User (Another Table?)
|
||||||
- Admin User?
|
- Admin User?
|
||||||
- Reset Passwords
|
- Reset Passwords
|
||||||
- Actually Delete Documents
|
- Actually Delete Documents
|
||||||
- Document & Activity Pagination
|
- Activity Pagination
|
||||||
|
|||||||
229
search/search.go
229
search/search.go
@@ -2,6 +2,7 @@ package search
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -16,8 +17,8 @@ import (
|
|||||||
type Cadence string
|
type Cadence string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TOP_YEAR Cadence = "y"
|
CADENCE_TOP_YEAR Cadence = "y"
|
||||||
TOP_MONTH Cadence = "m"
|
CADENCE_TOP_MONTH Cadence = "m"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BookType int
|
type BookType int
|
||||||
@@ -27,6 +28,14 @@ const (
|
|||||||
BOOK_NON_FICTION
|
BOOK_NON_FICTION
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Source string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SOURCE_ANNAS_ARCHIVE Source = "Annas Archive"
|
||||||
|
SOURCE_LIBGEN_FICTION Source = "LibGen Fiction"
|
||||||
|
SOURCE_LIBGEN_NON_FICTION Source = "LibGen Non-fiction"
|
||||||
|
)
|
||||||
|
|
||||||
type SearchItem struct {
|
type SearchItem struct {
|
||||||
ID string
|
ID string
|
||||||
Title string
|
Title string
|
||||||
@@ -38,26 +47,89 @@ type SearchItem struct {
|
|||||||
UploadDate string
|
UploadDate string
|
||||||
}
|
}
|
||||||
|
|
||||||
func SearchBook(query string, bookType BookType) ([]SearchItem, error) {
|
type sourceDef struct {
|
||||||
if bookType == BOOK_FICTION {
|
searchURL string
|
||||||
// Search Fiction
|
downloadURL string
|
||||||
url := "https://libgen.is/fiction/?q=" + url.QueryEscape(query) + "&language=English&format=epub"
|
parseSearchFunc func(io.ReadCloser) ([]SearchItem, error)
|
||||||
body, err := getPage(url)
|
parseDownloadFunc func(io.ReadCloser) (string, error)
|
||||||
if err != nil {
|
}
|
||||||
return nil, err
|
|
||||||
}
|
var sourceDefs = map[Source]sourceDef{
|
||||||
return parseLibGenFiction(body)
|
SOURCE_ANNAS_ARCHIVE: {
|
||||||
} else if bookType == BOOK_NON_FICTION {
|
searchURL: "https://annas-archive.org/search?index=&q=%s&ext=epub&sort=&lang=en",
|
||||||
// Search NonFiction
|
downloadURL: "http://libgen.li/ads.php?md5=%s",
|
||||||
url := "https://libgen.is/search.php?req=" + url.QueryEscape(query)
|
parseSearchFunc: parseAnnasArchive,
|
||||||
body, err := getPage(url)
|
parseDownloadFunc: parseAnnasArchiveDownloadURL,
|
||||||
if err != nil {
|
},
|
||||||
return nil, err
|
SOURCE_LIBGEN_FICTION: {
|
||||||
}
|
searchURL: "https://libgen.is/fiction/?q=%s&language=English&format=epub",
|
||||||
return parseLibGenNonFiction(body)
|
downloadURL: "http://library.lol/fiction/%s",
|
||||||
} else {
|
parseSearchFunc: parseLibGenFiction,
|
||||||
return nil, errors.New("Invalid Book Type")
|
parseDownloadFunc: parseLibGenDownloadURL,
|
||||||
|
},
|
||||||
|
SOURCE_LIBGEN_NON_FICTION: {
|
||||||
|
searchURL: "https://libgen.is/search.php?req=%s",
|
||||||
|
downloadURL: "http://library.lol/main/%s",
|
||||||
|
parseSearchFunc: parseLibGenNonFiction,
|
||||||
|
parseDownloadFunc: parseLibGenDownloadURL,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func SearchBook(query string, source Source) ([]SearchItem, error) {
|
||||||
|
def := sourceDefs[source]
|
||||||
|
log.Debug("[SearchBook] Source: ", def)
|
||||||
|
url := fmt.Sprintf(def.searchURL, url.QueryEscape(query))
|
||||||
|
body, err := getPage(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return def.parseSearchFunc(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveBook(id string, source Source) (string, error) {
|
||||||
|
def := sourceDefs[source]
|
||||||
|
log.Debug("[SaveBook] Source: ", def)
|
||||||
|
url := fmt.Sprintf(def.downloadURL, id)
|
||||||
|
|
||||||
|
body, err := getPage(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
bookURL, err := def.parseDownloadFunc(body)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("[SaveBook] Parse Download URL Error: ", err)
|
||||||
|
return "", errors.New("Download Failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create File
|
||||||
|
tempFile, err := os.CreateTemp("", "book")
|
||||||
|
if err != nil {
|
||||||
|
log.Error("[SaveBook] File Create Error: ", err)
|
||||||
|
return "", errors.New("File Failure")
|
||||||
|
}
|
||||||
|
defer tempFile.Close()
|
||||||
|
|
||||||
|
// Download File
|
||||||
|
log.Info("[SaveBook] Downloading Book: ", bookURL)
|
||||||
|
resp, err := http.Get(bookURL)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tempFile.Name())
|
||||||
|
log.Error("[SaveBook] Cover URL API Failure")
|
||||||
|
return "", errors.New("API Failure")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Copy File to Disk
|
||||||
|
log.Info("[SaveBook] Saving Book")
|
||||||
|
_, err = io.Copy(tempFile, resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tempFile.Name())
|
||||||
|
log.Error("[SaveBook] File Copy Error")
|
||||||
|
return "", errors.New("File Failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempFile.Name(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GoodReadsMostRead(c Cadence) ([]SearchItem, error) {
|
func GoodReadsMostRead(c Cadence) ([]SearchItem, error) {
|
||||||
@@ -87,57 +159,9 @@ func GetBookURL(id string, bookType BookType) (string, error) {
|
|||||||
return parseLibGenDownloadURL(body)
|
return parseLibGenDownloadURL(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func SaveBook(id string, bookType BookType) (string, error) {
|
|
||||||
// Derive Info URL
|
|
||||||
var infoURL string
|
|
||||||
if bookType == BOOK_FICTION {
|
|
||||||
infoURL = "http://library.lol/fiction/" + id
|
|
||||||
} else if bookType == BOOK_NON_FICTION {
|
|
||||||
infoURL = "http://library.lol/main/" + id
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse & Derive Download URL
|
|
||||||
body, err := getPage(infoURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
bookURL, err := parseLibGenDownloadURL(body)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("[SaveBook] Parse Download URL Error: ", err)
|
|
||||||
return "", errors.New("Download Failure")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create File
|
|
||||||
tempFile, err := os.CreateTemp("", "book")
|
|
||||||
if err != nil {
|
|
||||||
log.Error("[SaveBook] File Create Error: ", err)
|
|
||||||
return "", errors.New("File Failure")
|
|
||||||
}
|
|
||||||
defer tempFile.Close()
|
|
||||||
|
|
||||||
// Download File
|
|
||||||
log.Info("[SaveBook] Downloading Book")
|
|
||||||
resp, err := http.Get(bookURL)
|
|
||||||
if err != nil {
|
|
||||||
os.Remove(tempFile.Name())
|
|
||||||
log.Error("[SaveBook] Cover URL API Failure")
|
|
||||||
return "", errors.New("API Failure")
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// Copy File to Disk
|
|
||||||
log.Info("[SaveBook] Saving Book")
|
|
||||||
_, err = io.Copy(tempFile, resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
os.Remove(tempFile.Name())
|
|
||||||
log.Error("[SaveBook] File Copy Error")
|
|
||||||
return "", errors.New("File Failure")
|
|
||||||
}
|
|
||||||
|
|
||||||
return tempFile.Name(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPage(page string) (io.ReadCloser, error) {
|
func getPage(page string) (io.ReadCloser, error) {
|
||||||
|
log.Debug("[getPage] ", page)
|
||||||
|
|
||||||
// Set 10s Timeout
|
// Set 10s Timeout
|
||||||
client := http.Client{
|
client := http.Client{
|
||||||
Timeout: 10 * time.Second,
|
Timeout: 10 * time.Second,
|
||||||
@@ -292,3 +316,66 @@ func parseGoodReads(body io.ReadCloser) ([]SearchItem, error) {
|
|||||||
// Return Results
|
// Return Results
|
||||||
return allEntries, nil
|
return allEntries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseAnnasArchiveDownloadURL(body io.ReadCloser) (string, error) {
|
||||||
|
// Parse
|
||||||
|
defer body.Close()
|
||||||
|
doc, _ := goquery.NewDocumentFromReader(body)
|
||||||
|
|
||||||
|
// Return Download URL
|
||||||
|
downloadURL, exists := doc.Find("body > table > tbody > tr > td > a").Attr("href")
|
||||||
|
if exists == false {
|
||||||
|
return "", errors.New("Download URL not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return "http://libgen.li/" + downloadURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAnnasArchive(body io.ReadCloser) ([]SearchItem, error) {
|
||||||
|
// Parse
|
||||||
|
defer body.Close()
|
||||||
|
doc, err := goquery.NewDocumentFromReader(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize Results
|
||||||
|
var allEntries []SearchItem
|
||||||
|
doc.Find("form > div.w-full > div.w-full > div > div.justify-center").Each(func(ix int, rawBook *goquery.Selection) {
|
||||||
|
// Parse Details
|
||||||
|
details := rawBook.Find("div:nth-child(2) > div:nth-child(1)").Text()
|
||||||
|
detailsSplit := strings.Split(details, ", ")
|
||||||
|
|
||||||
|
// Invalid Details
|
||||||
|
if len(detailsSplit) < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
language := detailsSplit[0]
|
||||||
|
fileType := detailsSplit[1]
|
||||||
|
fileSize := detailsSplit[2]
|
||||||
|
|
||||||
|
// Get Title & Author
|
||||||
|
title := rawBook.Find("h3").Text()
|
||||||
|
author := rawBook.Find("div:nth-child(2) > div:nth-child(4)").Text()
|
||||||
|
|
||||||
|
// Parse MD5
|
||||||
|
itemHref, _ := rawBook.Find("a").Attr("href")
|
||||||
|
hrefArray := strings.Split(itemHref, "/")
|
||||||
|
id := hrefArray[len(hrefArray)-1]
|
||||||
|
|
||||||
|
item := SearchItem{
|
||||||
|
ID: id,
|
||||||
|
Title: title,
|
||||||
|
Author: author,
|
||||||
|
Language: language,
|
||||||
|
FileType: fileType,
|
||||||
|
FileSize: fileSize,
|
||||||
|
}
|
||||||
|
|
||||||
|
allEntries = append(allEntries, item)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Return Results
|
||||||
|
return allEntries, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<p>{{ $activity.Duration }}</p>
|
<p>{{ $activity.Duration }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td class="p-3 border-b border-gray-200">
|
<td class="p-3 border-b border-gray-200">
|
||||||
<p>{{ $activity.ReadPercentage }}%</p>
|
<p>{{ $activity.EndPercentage }}%</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
id="search"
|
id="search"
|
||||||
name="search"
|
name="search"
|
||||||
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-2 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||||
placeholder="Search Author / Title"
|
placeholder="Search Author / Title"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,7 +36,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex relative min-w-[12em]">
|
<div class="flex relative min-w-[12em]">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
|
||||||
@@ -61,11 +60,12 @@
|
|||||||
</span>
|
</span>
|
||||||
<select
|
<select
|
||||||
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
|
||||||
id="book_type"
|
id="source"
|
||||||
name="book_type"
|
name="source"
|
||||||
>
|
>
|
||||||
<option value="FICTION">Fiction</option>
|
<option value="Annas Archive">Annas Archive</option>
|
||||||
<option value="NON_FICTION">Non-Fiction</option>
|
<option value="LibGen Fiction">LibGen Fiction</option>
|
||||||
|
<option value="LibGen Non-fiction">LibGen Non-fiction</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -136,9 +136,9 @@
|
|||||||
<input
|
<input
|
||||||
class="hidden"
|
class="hidden"
|
||||||
type="text"
|
type="text"
|
||||||
id="book_type"
|
id="source"
|
||||||
name="book_type"
|
name="source"
|
||||||
value="{{ $.BookType }}"
|
value="{{ $.Source }}"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
class="hidden"
|
class="hidden"
|
||||||
|
|||||||
Reference in New Issue
Block a user