Compare commits
No commits in common. "0917172d1c2553aa39f4a739ba01d1be29011086" and "1b8b5060f1ca8d540ebf6a071617aae3cd725185" have entirely different histories.
0917172d1c
...
1b8b5060f1
42
README.md
@ -42,9 +42,8 @@ In additional to the compatible KOSync API's, we add:
|
|||||||
- Additional APIs to automatically upload reading statistics
|
- Additional APIs to automatically upload reading statistics
|
||||||
- Upload documents to the server (can download in the "Documents" view or via OPDS)
|
- Upload documents to the server (can download in the "Documents" view or via OPDS)
|
||||||
- Book metadata scraping (Thanks [OpenLibrary](https://openlibrary.org/) & [Google Books API](https://developers.google.com/books/docs/v1/getting_started))
|
- Book metadata scraping (Thanks [OpenLibrary](https://openlibrary.org/) & [Google Books API](https://developers.google.com/books/docs/v1/getting_started))
|
||||||
- Limited JavaScript use. Server-Side Rendering is used wherever possible. The main app is fully operational without any JS. JS is only required for:
|
- No JavaScript for the main app! All information is generated server side with go templates.
|
||||||
- EPUB Reader
|
- JavaScript is used for the ePub reader. Goals to make it service worker to enable a complete offline PWA reading experience.
|
||||||
- Offline Mode / Service Worker
|
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
|
|
||||||
@ -77,24 +76,25 @@ The service is now accessible at: `http://localhost:8585`. I recommend registeri
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
| Environment Variable | Default Value | Description |
|
| Environment Variable | Default Value | Description |
|
||||||
| -------------------- | ------------- | ------------------------------------------------------------------- |
|
| -------------------- | ------------- | -------------------------------------------------------------------- |
|
||||||
| DATABASE_TYPE | SQLite | Currently only "SQLite" is supported |
|
| DATABASE_TYPE | SQLite | Currently only "SQLite" is supported |
|
||||||
| DATABASE_NAME | book_manager | The database name, or in SQLite's case, the filename |
|
| DATABASE_NAME | bbank | The database name, or in SQLite's case, the filename |
|
||||||
| CONFIG_PATH | /config | Directory where to store SQLite's DB |
|
| DATABASE_PASSWORD | <EMPTY> | Currently not used. Placeholder for potential alternative DB support |
|
||||||
| DATA_PATH | /data | Directory where to store the documents and cover metadata |
|
| CONFIG_PATH | /config | Directory where to store SQLite's DB |
|
||||||
| LISTEN_PORT | 8585 | Port the server listens at |
|
| DATA_PATH | /data | Directory where to store the documents and cover metadata |
|
||||||
| REGISTRATION_ENABLED | false | Whether to allow registration (applies to both WebApp & KOSync API) |
|
| LISTEN_PORT | 8585 | Port the server listens at |
|
||||||
| COOKIE_SESSION_KEY | <EMPTY> | Optional secret cookie session key (auto generated if not provided) |
|
| REGISTRATION_ENABLED | false | Whether to allow registration (applies to both WebApp & KOSync API) |
|
||||||
| COOKIE_SECURE | true | Set Cookie `Secure` attribute (i.e. only works over HTTPS) |
|
| COOKIE_SESSION_KEY | <EMPTY> | Optional secret cookie session key (auto generated if not provided) |
|
||||||
| COOKIE_HTTP_ONLY | true | Set Cookie `HttpOnly` attribute (i.e. inacessible via JavaScript) |
|
| COOKIE_SECURE | true | Set Cookie `Secure` attribute (i.e. only works over HTTPS) |
|
||||||
|
| COOKIE_HTTP_ONLY | true | Set Cookie `HttpOnly` attribute (i.e. inacessible via JavaScript) |
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
- _Web App / PWA_ - Session based token (7 day expiry, refresh after 6 days)
|
- _Web App / PWA_ - Session based token (7 day expiry, refresh after 6 days)
|
||||||
- _KOSync & SyncNinja API_ - Header based - `X-Auth-User` & `X-Auth-Key` (KOSync compatibility)
|
- _KOSync & SyncNinja API_ - Header based (KOSync compatibility)
|
||||||
- _OPDS API_ - Basic authentication (KOReader OPDS compatibility)
|
- _OPDS API_ - Basic authentication (KOReader OPDS compatibility)
|
||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
@ -119,7 +119,7 @@ go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
|
|||||||
Run Development:
|
Run Development:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
CONFIG_PATH=./data DATA_PATH=./data REGISTRATION_ENABLED=true go run main.go serve
|
CONFIG_PATH=./data DATA_PATH=./data go run main.go serve
|
||||||
```
|
```
|
||||||
|
|
||||||
# Building
|
# Building
|
||||||
@ -136,16 +136,6 @@ make docker_build_local
|
|||||||
# Build Docker & Push Latest or Dev (Linux - arm64 & amd64)
|
# Build Docker & Push Latest or Dev (Linux - arm64 & amd64)
|
||||||
make docker_build_release_latest
|
make docker_build_release_latest
|
||||||
make docker_build_release_dev
|
make docker_build_release_dev
|
||||||
|
|
||||||
# Generate Tailwind CSS
|
|
||||||
make build_tailwind
|
|
||||||
|
|
||||||
# Clean Local Build
|
|
||||||
make clean
|
|
||||||
|
|
||||||
# Tests (Unit & Integration - Google Books API)
|
|
||||||
make tests_unit
|
|
||||||
make tests_integration
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
11
api/api.go
@ -80,6 +80,7 @@ func (api *API) registerWebAppRoutes() {
|
|||||||
|
|
||||||
render.AddFromFiles("error", "templates/error.html")
|
render.AddFromFiles("error", "templates/error.html")
|
||||||
render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html")
|
render.AddFromFilesFuncs("login", helperFuncs, "templates/login.html")
|
||||||
|
render.AddFromFilesFuncs("reader", helperFuncs, "templates/reader-base.html", "templates/reader.html")
|
||||||
render.AddFromFilesFuncs("home", helperFuncs, "templates/base.html", "templates/home.html")
|
render.AddFromFilesFuncs("home", helperFuncs, "templates/base.html", "templates/home.html")
|
||||||
render.AddFromFilesFuncs("search", helperFuncs, "templates/base.html", "templates/search.html")
|
render.AddFromFilesFuncs("search", helperFuncs, "templates/base.html", "templates/search.html")
|
||||||
render.AddFromFilesFuncs("settings", helperFuncs, "templates/base.html", "templates/settings.html")
|
render.AddFromFilesFuncs("settings", helperFuncs, "templates/base.html", "templates/settings.html")
|
||||||
@ -89,15 +90,7 @@ func (api *API) registerWebAppRoutes() {
|
|||||||
|
|
||||||
api.Router.HTMLRender = render
|
api.Router.HTMLRender = render
|
||||||
|
|
||||||
// Static Assets (Require @ Root)
|
|
||||||
api.Router.GET("/manifest.json", api.webManifest)
|
api.Router.GET("/manifest.json", api.webManifest)
|
||||||
api.Router.GET("/sw.js", api.serviceWorker)
|
|
||||||
|
|
||||||
// Offline Static Pages (No Template)
|
|
||||||
api.Router.GET("/offline", api.offlineDocuments)
|
|
||||||
api.Router.GET("/reader", api.documentReader)
|
|
||||||
|
|
||||||
// Template App
|
|
||||||
api.Router.GET("/login", api.createAppResourcesRoute("login"))
|
api.Router.GET("/login", api.createAppResourcesRoute("login"))
|
||||||
api.Router.GET("/register", api.createAppResourcesRoute("login", gin.H{"Register": true}))
|
api.Router.GET("/register", api.createAppResourcesRoute("login", gin.H{"Register": true}))
|
||||||
api.Router.GET("/logout", api.authWebAppMiddleware, api.authLogout)
|
api.Router.GET("/logout", api.authWebAppMiddleware, api.authLogout)
|
||||||
@ -111,12 +104,12 @@ func (api *API) registerWebAppRoutes() {
|
|||||||
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
|
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
|
||||||
api.Router.POST("/documents", api.authWebAppMiddleware, api.uploadNewDocument)
|
api.Router.POST("/documents", api.authWebAppMiddleware, api.uploadNewDocument)
|
||||||
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/reader", api.authWebAppMiddleware, api.documentReader)
|
||||||
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/cover", api.authWebAppMiddleware, api.getDocumentCover)
|
api.Router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.getDocumentCover)
|
||||||
api.Router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.editDocument)
|
api.Router.POST("/documents/:document/edit", api.authWebAppMiddleware, api.editDocument)
|
||||||
api.Router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.identifyDocument)
|
api.Router.POST("/documents/:document/identify", api.authWebAppMiddleware, api.identifyDocument)
|
||||||
api.Router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.deleteDocument)
|
api.Router.POST("/documents/:document/delete", api.authWebAppMiddleware, api.deleteDocument)
|
||||||
api.Router.GET("/documents/:document/progress", api.authWebAppMiddleware, api.getDocumentProgress)
|
|
||||||
|
|
||||||
// Behind Configuration Flag
|
// Behind Configuration Flag
|
||||||
if api.Config.SearchEnabled {
|
if api.Config.SearchEnabled {
|
||||||
|
@ -72,18 +72,6 @@ func (api *API) webManifest(c *gin.Context) {
|
|||||||
c.File("./assets/manifest.json")
|
c.File("./assets/manifest.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) serviceWorker(c *gin.Context) {
|
|
||||||
c.File("./assets/sw.js")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) offlineDocuments(c *gin.Context) {
|
|
||||||
c.File("./assets/offline/index.html")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) documentReader(c *gin.Context) {
|
|
||||||
c.File("./assets/reader/index.html")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any) func(*gin.Context) {
|
func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any) func(*gin.Context) {
|
||||||
// Merge Optional Template Data
|
// Merge Optional Template Data
|
||||||
var templateVarsBase = gin.H{}
|
var templateVarsBase = gin.H{}
|
||||||
@ -257,7 +245,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
|
|||||||
// Handle Identified Document
|
// Handle Identified Document
|
||||||
if document.Coverfile != nil {
|
if document.Coverfile != nil {
|
||||||
if *document.Coverfile == "UNKNOWN" {
|
if *document.Coverfile == "UNKNOWN" {
|
||||||
c.File("./assets/images/no-cover.jpg")
|
c.File("./assets/no-cover.jpg")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -268,7 +256,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
|
|||||||
_, err = os.Stat(safePath)
|
_, err = os.Stat(safePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[getDocumentCover] File Should But Doesn't Exist:", err)
|
log.Error("[getDocumentCover] File Should But Doesn't Exist:", err)
|
||||||
c.File("./assets/images/no-cover.jpg")
|
c.File("./assets/no-cover.jpg")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -321,7 +309,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
|
|||||||
|
|
||||||
// Return Unknown Cover
|
// Return Unknown Cover
|
||||||
if coverFile == "UNKNOWN" {
|
if coverFile == "UNKNOWN" {
|
||||||
c.File("./assets/images/no-cover.jpg")
|
c.File("./assets/no-cover.jpg")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -329,12 +317,12 @@ func (api *API) getDocumentCover(c *gin.Context) {
|
|||||||
c.File(coverFilePath)
|
c.File(coverFilePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *API) getDocumentProgress(c *gin.Context) {
|
func (api *API) documentReader(c *gin.Context) {
|
||||||
rUser, _ := c.Get("AuthorizedUser")
|
rUser, _ := c.Get("AuthorizedUser")
|
||||||
|
|
||||||
var rDoc requestDocumentID
|
var rDoc requestDocumentID
|
||||||
if err := c.ShouldBindUri(&rDoc); err != nil {
|
if err := c.ShouldBindUri(&rDoc); err != nil {
|
||||||
log.Error("[getDocumentProgress] Invalid URI Bind")
|
log.Error("[documentReader] Invalid URI Bind")
|
||||||
errorPage(c, http.StatusNotFound, "Invalid document.")
|
errorPage(c, http.StatusNotFound, "Invalid document.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -345,7 +333,7 @@ func (api *API) getDocumentProgress(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if err != nil && err != sql.ErrNoRows {
|
if err != nil && err != sql.ErrNoRows {
|
||||||
log.Error("[getDocumentProgress] UpsertDocument DB Error:", err)
|
log.Error("[documentReader] UpsertDocument DB Error:", err)
|
||||||
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
|
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -355,18 +343,15 @@ func (api *API) getDocumentProgress(c *gin.Context) {
|
|||||||
DocumentID: rDoc.DocumentID,
|
DocumentID: rDoc.DocumentID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("[getDocumentProgress] GetDocumentWithStats DB Error:", err)
|
log.Error("[documentReader] GetDocumentWithStats DB Error:", err)
|
||||||
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentWithStats DB Error: %v", err))
|
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentWithStats DB Error: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.HTML(http.StatusOK, "reader", gin.H{
|
||||||
"id": document.ID,
|
"SearchEnabled": api.Config.SearchEnabled,
|
||||||
"title": document.Title,
|
"Progress": progress.Progress,
|
||||||
"author": document.Author,
|
"Data": document,
|
||||||
"words": document.Words,
|
|
||||||
"progress": progress.Progress,
|
|
||||||
"percentage": document.Percentage,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -636,7 +636,7 @@ func (api *API) downloadDocument(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Force Download (Security)
|
// Force Download (Security)
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(*document.Filepath)))
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filepath.Base(*document.Filepath)))
|
||||||
c.File(filePath)
|
c.File(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 511 KiB After Width: | Height: | Size: 511 KiB |
Before Width: | Height: | Size: 699 KiB After Width: | Height: | Size: 699 KiB |
Before Width: | Height: | Size: 462 KiB After Width: | Height: | Size: 462 KiB |
Before Width: | Height: | Size: 457 KiB After Width: | Height: | Size: 457 KiB |
@ -1,78 +0,0 @@
|
|||||||
// Install Service Worker
|
|
||||||
async function installServiceWorker() {
|
|
||||||
// Attempt Installation
|
|
||||||
await SW.install()
|
|
||||||
.then(() => console.log("[installServiceWorker] Service Worker Installed"))
|
|
||||||
.catch((e) =>
|
|
||||||
console.log("[installServiceWorker] Service Worker Install Error:", e)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush Cached Progress & Activity
|
|
||||||
async function flushCachedData() {
|
|
||||||
let allProgress = await IDB.find(/^PROGRESS-/, true);
|
|
||||||
let allActivity = await IDB.get("ACTIVITY");
|
|
||||||
|
|
||||||
console.log("[flushCachedData] Flushing Data:", { allProgress, allActivity });
|
|
||||||
|
|
||||||
Object.entries(allProgress).forEach(([id, progressEvent]) => {
|
|
||||||
flushProgress(progressEvent)
|
|
||||||
.then(() => {
|
|
||||||
console.log("[flushCachedData] Progress Flush Success:", id);
|
|
||||||
return IDB.del(id);
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.log("[flushCachedData] Progress Flush Failure:", id, e);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!allActivity) return;
|
|
||||||
|
|
||||||
flushActivity(allActivity)
|
|
||||||
.then(() => {
|
|
||||||
console.log("[flushCachedData] Activity Flush Success");
|
|
||||||
return IDB.del("ACTIVITY");
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.log("[flushCachedData] Activity Flush Failure", e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushActivity(activityEvent) {
|
|
||||||
console.log("[flushActivity] Flushing Activity...");
|
|
||||||
|
|
||||||
// Flush Activity
|
|
||||||
return fetch("/api/ko/activity", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(activityEvent),
|
|
||||||
}).then(async (r) =>
|
|
||||||
console.log("[flushActivity] Flushed Activity:", {
|
|
||||||
response: r,
|
|
||||||
json: await r.json(),
|
|
||||||
data: activityEvent,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushProgress(progressEvent) {
|
|
||||||
console.log("[flushProgress] Flushing Progress...");
|
|
||||||
|
|
||||||
// Flush Progress
|
|
||||||
return fetch("/api/ko/syncs/progress", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(progressEvent),
|
|
||||||
}).then(async (r) =>
|
|
||||||
console.log("[flushProgress] Flushed Progress:", {
|
|
||||||
response: r,
|
|
||||||
json: await r.json(),
|
|
||||||
data: progressEvent,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Event Listeners
|
|
||||||
window.addEventListener("online", flushCachedData);
|
|
||||||
|
|
||||||
// Initial Load
|
|
||||||
flushCachedData();
|
|
||||||
installServiceWorker();
|
|
@ -1,50 +0,0 @@
|
|||||||
function _slicedToArray(t,n){return _arrayWithHoles(t)||_iterableToArrayLimit(t,n)||_unsupportedIterableToArray(t,n)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _unsupportedIterableToArray(t,n){if(t){if("string"==typeof t)return _arrayLikeToArray(t,n);var r=Object.prototype.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?_arrayLikeToArray(t,n):void 0}}function _arrayLikeToArray(t,n){(null==n||n>t.length)&&(n=t.length);for(var r=0,e=new Array(n);r<n;r++)e[r]=t[r];return e}function _iterableToArrayLimit(t,n){var r=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=r){var e,o,u=[],i=!0,a=!1;try{for(r=r.call(t);!(i=(e=r.next()).done)&&(u.push(e.value),!n||u.length!==n);i=!0);}catch(t){a=!0,o=t}finally{try{i||null==r.return||r.return()}finally{if(a)throw o}}return u}}function _arrayWithHoles(t){if(Array.isArray(t))return t}function _typeof(t){return _typeof="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},_typeof(t)}!function(t,n){"object"===("undefined"==typeof exports?"undefined":_typeof(exports))&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((t="undefined"!=typeof globalThis?globalThis:t||self).idbKeyval={})}(this,(function(t){"use strict";function n(t){return new Promise((function(n,r){t.oncomplete=t.onsuccess=function(){return n(t.result)},t.onabort=t.onerror=function(){return r(t.error)}}))}function r(t,r){var e=indexedDB.open(t);e.onupgradeneeded=function(){return e.result.createObjectStore(r)};var o=n(e);return function(t,n){return o.then((function(e){return n(e.transaction(r,t).objectStore(r))}))}}var e;function o(){return e||(e=r("keyval-store","keyval")),e}function u(t,r){return t.openCursor().onsuccess=function(){this.result&&(r(this.result),this.result.continue())},n(t.transaction)}t.clear=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readwrite",(function(t){return t.clear(),n(t.transaction)}))},t.createStore=r,t.del=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readwrite",(function(r){return r.delete(t),n(r.transaction)}))},t.delMany=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readwrite",(function(r){return t.forEach((function(t){return r.delete(t)})),n(r.transaction)}))},t.entries=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readonly",(function(r){if(r.getAll&&r.getAllKeys)return Promise.all([n(r.getAllKeys()),n(r.getAll())]).then((function(t){var n=_slicedToArray(t,2),r=n[0],e=n[1];return r.map((function(t,n){return[t,e[n]]}))}));var e=[];return t("readonly",(function(t){return u(t,(function(t){return e.push([t.key,t.value])})).then((function(){return e}))}))}))},t.get=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readonly",(function(r){return n(r.get(t))}))},t.getMany=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readonly",(function(r){return Promise.all(t.map((function(t){return n(r.get(t))})))}))},t.keys=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readonly",(function(t){if(t.getAllKeys)return n(t.getAllKeys());var r=[];return u(t,(function(t){return r.push(t.key)})).then((function(){return r}))}))},t.promisifyRequest=n,t.set=function(t,r){var e=arguments.length>2&&void 0!==arguments[2]?arguments[2]:o();return e("readwrite",(function(e){return e.put(r,t),n(e.transaction)}))},t.setMany=function(t){var r=arguments.length>1&&void 0!==arguments[1]?arguments[1]:o();return r("readwrite",(function(r){return t.forEach((function(t){return r.put(t[1],t[0])})),n(r.transaction)}))},t.update=function(t,r){var e=arguments.length>2&&void 0!==arguments[2]?arguments[2]:o();return e("readwrite",(function(e){return new Promise((function(o,u){e.get(t).onsuccess=function(){try{e.put(r(this.result),t),o(n(e.transaction))}catch(t){u(t)}}}))}))},t.values=function(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:o();return t("readonly",(function(t){if(t.getAll)return n(t.getAll());var r=[];return u(t,(function(t){return r.push(t.value)})).then((function(){return r}))}))},Object.defineProperty(t,"__esModule",{value:!0})}));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom IDB Convenience Functions Wrapper
|
|
||||||
**/
|
|
||||||
const IDB = (function () {
|
|
||||||
let { get, del, entries, update, keys } = idbKeyval;
|
|
||||||
|
|
||||||
return {
|
|
||||||
async set(key, newValue) {
|
|
||||||
let changeObj = {};
|
|
||||||
await update(key, (oldValue) => {
|
|
||||||
if (oldValue != null) changeObj.oldValue = oldValue;
|
|
||||||
changeObj.newValue = newValue;
|
|
||||||
return newValue;
|
|
||||||
});
|
|
||||||
return changeObj;
|
|
||||||
},
|
|
||||||
|
|
||||||
get(key, defaultValue) {
|
|
||||||
return get(key).then((resp) => {
|
|
||||||
return defaultValue && resp == null ? defaultValue : resp;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
del(key) {
|
|
||||||
return del(key);
|
|
||||||
},
|
|
||||||
|
|
||||||
find(keyRegExp, includeValues = false) {
|
|
||||||
if (!(keyRegExp instanceof RegExp)) throw new Error("Invalid RegExp");
|
|
||||||
|
|
||||||
if (!includeValues)
|
|
||||||
return keys().then((allKeys) =>
|
|
||||||
allKeys.filter((key) => keyRegExp.test(key))
|
|
||||||
);
|
|
||||||
|
|
||||||
return entries().then((allItems) => {
|
|
||||||
const matchingKeys = allItems.filter((keyVal) =>
|
|
||||||
keyRegExp.test(keyVal[0])
|
|
||||||
);
|
|
||||||
return matchingKeys.reduce((obj, keyVal) => {
|
|
||||||
const [key, val] = keyVal;
|
|
||||||
obj[key] = val;
|
|
||||||
return obj;
|
|
||||||
}, {});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})();
|
|
@ -1,60 +0,0 @@
|
|||||||
const SW = (function () {
|
|
||||||
// Helper Function
|
|
||||||
function randomID() {
|
|
||||||
return "00000000000000000000000000000000".replace(/[018]/g, (c) =>
|
|
||||||
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))))
|
|
||||||
.toString(16)
|
|
||||||
.toUpperCase()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Variables
|
|
||||||
let swInstance = null;
|
|
||||||
let outstandingMessages = {};
|
|
||||||
|
|
||||||
navigator.serviceWorker.addEventListener("message", ({ data }) => {
|
|
||||||
let { id } = data;
|
|
||||||
data = data.data;
|
|
||||||
|
|
||||||
console.log("[SW] Received Message:", { id, data });
|
|
||||||
if (!outstandingMessages[id])
|
|
||||||
return console.warn("[SW] Invalid Outstanding Message:", { id, data });
|
|
||||||
|
|
||||||
outstandingMessages[id](data);
|
|
||||||
delete outstandingMessages[id];
|
|
||||||
});
|
|
||||||
|
|
||||||
async function install() {
|
|
||||||
// Register Service Worker
|
|
||||||
swInstance = await navigator.serviceWorker.register("/sw.js");
|
|
||||||
swInstance.onupdatefound = (data) =>
|
|
||||||
console.log("[SW.install] Update Found:", data);
|
|
||||||
|
|
||||||
// Wait for Registration / Update
|
|
||||||
let serviceWorker =
|
|
||||||
swInstance.installing || swInstance.waiting || swInstance.active;
|
|
||||||
|
|
||||||
// Await Installation
|
|
||||||
await new Promise((resolve) => {
|
|
||||||
serviceWorker.onstatechange = (data) => {
|
|
||||||
console.log("[SW.install] State Change:", serviceWorker.state);
|
|
||||||
if (serviceWorker.state == "activated") resolve();
|
|
||||||
};
|
|
||||||
if (serviceWorker.state == "activated") resolve();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function send(data) {
|
|
||||||
if (!swInstance?.active) return Promise.reject("Inactive Service Worker");
|
|
||||||
let id = randomID();
|
|
||||||
|
|
||||||
let msgPromise = new Promise((resolve) => {
|
|
||||||
outstandingMessages[id] = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
swInstance.active.postMessage({ id, data });
|
|
||||||
return msgPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { install, send };
|
|
||||||
})();
|
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 213 KiB |
@ -1,179 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=0.90, user-scalable=no, viewport-fit=cover"
|
|
||||||
/>
|
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta
|
|
||||||
name="apple-mobile-web-app-status-bar-style"
|
|
||||||
content="black-translucent"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
name="theme-color"
|
|
||||||
content="#F3F4F6"
|
|
||||||
media="(prefers-color-scheme: light)"
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
name="theme-color"
|
|
||||||
content="#1F2937"
|
|
||||||
media="(prefers-color-scheme: dark)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<title>Book Manager - Offline</title>
|
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
<link rel="stylesheet" href="/assets/style.css" />
|
|
||||||
|
|
||||||
<script src="/assets/lib/idb-keyval.js"></script>
|
|
||||||
<script src="/assets/lib/sw-helper.js"></script>
|
|
||||||
<script src="/assets/index.js"></script>
|
|
||||||
<script src="/assets/offline/index.js"></script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* ----------------------------- */
|
|
||||||
/* -------- PWA Styling -------- */
|
|
||||||
/* ----------------------------- */
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
overscroll-behavior-y: none;
|
|
||||||
margin: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
height: calc(100% + env(safe-area-inset-bottom));
|
|
||||||
padding: env(safe-area-inset-top) env(safe-area-inset-right) 0
|
|
||||||
env(safe-area-inset-left);
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
height: calc(100dvh - 4rem - env(safe-area-inset-top));
|
|
||||||
}
|
|
||||||
|
|
||||||
#container {
|
|
||||||
padding-bottom: calc(5em + env(safe-area-inset-bottom) * 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* No Scrollbar - IE, Edge, Firefox */
|
|
||||||
* {
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* No Scrollbar - WebKit */
|
|
||||||
*::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.css-button:checked + div {
|
|
||||||
display: block;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.css-button + div {
|
|
||||||
display: none;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.css-button:checked + div + label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-100 dark:bg-gray-800">
|
|
||||||
<div class="flex items-center justify-between w-full h-16">
|
|
||||||
<h1 class="text-xl font-bold dark:text-white px-6 lg:ml-48">
|
|
||||||
Offline Documents
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main class="relative overflow-hidden">
|
|
||||||
<div
|
|
||||||
id="container"
|
|
||||||
class="h-[100dvh] px-4 overflow-auto md:px-6 lg:mx-48"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
id="online"
|
|
||||||
class="rounded text-white bg-white dark:bg-gray-700 text-center p-3 mb-4"
|
|
||||||
>
|
|
||||||
You're Online:
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
class="p-2 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
|
||||||
>Go Home</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="loading"
|
|
||||||
class="rounded text-white bg-white dark:bg-gray-700 text-center p-3 mb-4"
|
|
||||||
>
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="items"
|
|
||||||
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
|
|
||||||
></div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="fixed bottom-6 right-6 rounded-full flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="add-file-button"
|
|
||||||
class="hidden css-button"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="rounded p-4 bg-gray-800 dark:bg-gray-200 text-white dark:text-black w-72 text-sm flex flex-col gap-2"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".epub"
|
|
||||||
id="document_file"
|
|
||||||
name="document_file"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="font-medium px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
Add File
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<label for="add-file-button">
|
|
||||||
<div
|
|
||||||
class="w-full text-center cursor-pointer font-medium mt-2 px-2 py-1 text-gray-800 bg-gray-500 dark:text-white hover:bg-gray-100 dark:hover:bg-gray-800"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label
|
|
||||||
class="w-16 h-16 bg-gray-800 dark:bg-gray-200 rounded-full flex items-center justify-center opacity-30 hover:opacity-100 transition-all duration-200 cursor-pointer"
|
|
||||||
for="add-file-button"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="34"
|
|
||||||
height="34"
|
|
||||||
class="text-gray-200 dark:text-gray-600"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M12 15.75C12.4142 15.75 12.75 15.4142 12.75 15V4.02744L14.4306 5.98809C14.7001 6.30259 15.1736 6.33901 15.4881 6.06944C15.8026 5.79988 15.839 5.3264 15.5694 5.01191L12.5694 1.51191C12.427 1.34567 12.2189 1.25 12 1.25C11.7811 1.25 11.573 1.34567 11.4306 1.51191L8.43056 5.01191C8.16099 5.3264 8.19741 5.79988 8.51191 6.06944C8.8264 6.33901 9.29988 6.30259 9.56944 5.98809L11.25 4.02744L11.25 15C11.25 15.4142 11.5858 15.75 12 15.75Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M16 9C15.2978 9 14.9467 9 14.6945 9.16851C14.5853 9.24148 14.4915 9.33525 14.4186 9.44446C14.25 9.69667 14.25 10.0478 14.25 10.75L14.25 15C14.25 16.2426 13.2427 17.25 12 17.25C10.7574 17.25 9.75004 16.2426 9.75004 15L9.75004 10.75C9.75004 10.0478 9.75004 9.69664 9.58149 9.4444C9.50854 9.33523 9.41481 9.2415 9.30564 9.16855C9.05341 9 8.70227 9 8 9C5.17157 9 3.75736 9 2.87868 9.87868C2 10.7574 2 12.1714 2 14.9998V15.9998C2 18.8282 2 20.2424 2.87868 21.1211C3.75736 21.9998 5.17157 21.9998 8 21.9998H16C18.8284 21.9998 20.2426 21.9998 21.1213 21.1211C22 20.2424 22 18.8282 22 15.9998V14.9998C22 12.1714 22 10.7574 21.1213 9.87868C20.2426 9 18.8284 9 16 9Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,142 +0,0 @@
|
|||||||
/**
|
|
||||||
* TODO:
|
|
||||||
* - Offling / Online Checker
|
|
||||||
* - Flush oustanding read activity & progress
|
|
||||||
* - No files cached
|
|
||||||
* - Upload Files
|
|
||||||
**/
|
|
||||||
|
|
||||||
const BASE_ITEM = `
|
|
||||||
<div class="w-full relative">
|
|
||||||
<div class="flex gap-4 w-full h-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded">
|
|
||||||
<div class="min-w-fit my-auto h-48 relative">
|
|
||||||
<a href="#">
|
|
||||||
<img class="rounded object-cover h-full" src="/assets/images/no-cover.jpg"></img>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col justify-around dark:text-white w-full text-sm">
|
|
||||||
<div class="inline-flex shrink-0 items-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-400">Title</p>
|
|
||||||
<p class="font-medium">
|
|
||||||
N/A
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="inline-flex shrink-0 items-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-400">Author</p>
|
|
||||||
<p class="font-medium">
|
|
||||||
N/A
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="inline-flex shrink-0 items-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-gray-400">Progress</p>
|
|
||||||
<p class="font-medium">
|
|
||||||
0%
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="absolute flex flex-col gap-2 right-4 bottom-4 text-gray-500 dark:text-gray-400">
|
|
||||||
<a href="#">
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
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="M2 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 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12ZM12 6.25C12.4142 6.25 12.75 6.58579 12.75 7V12.1893L14.4697 10.4697C14.7626 10.1768 15.2374 10.1768 15.5303 10.4697C15.8232 10.7626 15.8232 11.2374 15.5303 11.5303L12.5303 14.5303C12.3897 14.671 12.1989 14.75 12 14.75C11.8011 14.75 11.6103 14.671 11.4697 14.5303L8.46967 11.5303C8.17678 11.2374 8.17678 10.7626 8.46967 10.4697C8.76256 10.1768 9.23744 10.1768 9.53033 10.4697L11.25 12.1893V7C11.25 6.58579 11.5858 6.25 12 6.25ZM8 16.25C7.58579 16.25 7.25 16.5858 7.25 17C7.25 17.4142 7.58579 17.75 8 17.75H16C16.4142 17.75 16.75 17.4142 16.75 17C16.75 16.5858 16.4142 16.25 16 16.25H8Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const GET_SW_CACHE = "GET_SW_CACHE";
|
|
||||||
const DEL_SW_CACHE = "DEL_SW_CACHE";
|
|
||||||
|
|
||||||
async function initOffline() {
|
|
||||||
updateOnlineIndicator();
|
|
||||||
|
|
||||||
if (document.location.pathname !== "/offline")
|
|
||||||
window.history.replaceState(null, null, "/offline");
|
|
||||||
|
|
||||||
// Ensure Installed
|
|
||||||
await SW.install();
|
|
||||||
|
|
||||||
// Get Service Worker Cache & Local Cache - Override Local
|
|
||||||
let swCache = await SW.send({ type: GET_SW_CACHE });
|
|
||||||
let allCache = await Promise.all(
|
|
||||||
swCache.map(async (item) => {
|
|
||||||
let localCache = await IDB.get("PROGRESS-" + item.id);
|
|
||||||
if (localCache) {
|
|
||||||
item.progress = localCache.progress;
|
|
||||||
item.percentage = Math.round(localCache.percentage * 10000) / 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
return item;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
populateDOM(allCache);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Populate DOM with cached documents.
|
|
||||||
**/
|
|
||||||
function populateDOM(data) {
|
|
||||||
let allDocuments = document.querySelector("#items");
|
|
||||||
|
|
||||||
// Update Loader / No Results Indicator
|
|
||||||
let loadingEl = document.querySelector("#loading");
|
|
||||||
if (data.length == 0) loadingEl.innerText = "No Results";
|
|
||||||
else loadingEl.remove();
|
|
||||||
|
|
||||||
data.forEach((item) => {
|
|
||||||
// Create Main Element
|
|
||||||
let baseEl = document.createElement("div");
|
|
||||||
baseEl.innerHTML = BASE_ITEM;
|
|
||||||
baseEl = baseEl.firstElementChild;
|
|
||||||
|
|
||||||
// Get Elements
|
|
||||||
let coverEl = baseEl.querySelector("a img");
|
|
||||||
let [titleEl, authorEl, percentageEl] = baseEl.querySelectorAll("p + p");
|
|
||||||
let downloadEl = baseEl.querySelector("svg").parentElement;
|
|
||||||
|
|
||||||
// Set Variables
|
|
||||||
downloadEl.setAttribute("href", "/documents/" + item.id + "/file");
|
|
||||||
coverEl.setAttribute("src", "/documents/" + item.id + "/cover");
|
|
||||||
coverEl.parentElement.setAttribute("href", "/reader#id=" + item.id);
|
|
||||||
titleEl.textContent = item.title;
|
|
||||||
authorEl.textContent = item.author;
|
|
||||||
percentageEl.textContent = item.percentage + "%";
|
|
||||||
|
|
||||||
allDocuments.append(baseEl);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allow adding file to offline reader. Add to IndexedDB,
|
|
||||||
* and later upload? Add style indicating external file?
|
|
||||||
**/
|
|
||||||
function handleFileAdd() {}
|
|
||||||
|
|
||||||
function updateOnlineIndicator(isOnline) {
|
|
||||||
let onlineEl = document.querySelector("#online");
|
|
||||||
isOnline = isOnline == undefined ? navigator.onLine : isOnline;
|
|
||||||
onlineEl.hidden = !isOnline;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize
|
|
||||||
window.addEventListener("DOMContentLoaded", initOffline);
|
|
||||||
window.addEventListener("online", () => updateOnlineIndicator(true));
|
|
||||||
window.addEventListener("offline", () => updateOnlineIndicator(false));
|
|
@ -1,59 +1,6 @@
|
|||||||
const THEMES = ["light", "tan", "blue", "gray", "black"];
|
const THEMES = ["light", "tan", "blue", "gray", "black"];
|
||||||
const THEME_FILE = "/assets/reader/readerThemes.css";
|
const THEME_FILE = "/assets/reader/readerThemes.css";
|
||||||
|
|
||||||
async function initReader() {
|
|
||||||
let documentData;
|
|
||||||
let filePath;
|
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.hash.slice(1));
|
|
||||||
const documentID = urlParams.get("id");
|
|
||||||
const localID = urlParams.get("local");
|
|
||||||
|
|
||||||
if (documentID) {
|
|
||||||
// Get Server / Cached Document
|
|
||||||
let progressResp = await fetch("/documents/" + documentID + "/progress");
|
|
||||||
documentData = await progressResp.json();
|
|
||||||
|
|
||||||
// Update Local Cache
|
|
||||||
let localCache = await IDB.get("PROGRESS-" + documentID);
|
|
||||||
if (localCache) {
|
|
||||||
documentData.progress = localCache.progress;
|
|
||||||
documentData.percentage = Math.round(localCache.percentage * 10000) / 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
filePath = "/documents/" + documentID + "/file";
|
|
||||||
} else if (localID) {
|
|
||||||
// Get Local Document
|
|
||||||
// TODO:
|
|
||||||
// - IDB FileID
|
|
||||||
// - IDB Metadata
|
|
||||||
} else {
|
|
||||||
throw new Error("Invalid");
|
|
||||||
}
|
|
||||||
|
|
||||||
populateMetadata(documentData);
|
|
||||||
window.currentReader = new EBookReader(filePath, documentData);
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateMetadata(data) {
|
|
||||||
let documentLocation = data.id.startsWith("local-")
|
|
||||||
? "/offline"
|
|
||||||
: "/documents/" + data.id;
|
|
||||||
|
|
||||||
let documentCoverLocation = data.id.startsWith("local-")
|
|
||||||
? "/assets/images/no-cover.jpg"
|
|
||||||
: "/documents/" + data.id + "/cover";
|
|
||||||
|
|
||||||
let [backEl, coverEl] = document.querySelectorAll("a");
|
|
||||||
backEl.setAttribute("href", documentLocation);
|
|
||||||
coverEl.setAttribute("href", documentLocation);
|
|
||||||
coverEl.firstElementChild.setAttribute("src", documentCoverLocation);
|
|
||||||
|
|
||||||
let [titleEl, authorEl] = document.querySelectorAll("#top-bar p + p");
|
|
||||||
titleEl.innerText = data.title;
|
|
||||||
authorEl.innerText = data.author;
|
|
||||||
}
|
|
||||||
|
|
||||||
class EBookReader {
|
class EBookReader {
|
||||||
bookState = {
|
bookState = {
|
||||||
currentWord: 0,
|
currentWord: 0,
|
||||||
@ -114,7 +61,7 @@ class EBookReader {
|
|||||||
|
|
||||||
// Get Stats
|
// Get Stats
|
||||||
let stats = this.getBookStats();
|
let stats = this.getBookStats();
|
||||||
this.updateBookStatElements(stats);
|
this.updateBookStats(stats);
|
||||||
}.bind(this);
|
}.bind(this);
|
||||||
|
|
||||||
// Register Content Hook
|
// Register Content Hook
|
||||||
@ -434,41 +381,25 @@ class EBookReader {
|
|||||||
// ------------------------------------------------ //
|
// ------------------------------------------------ //
|
||||||
// --------------- Bottom & Top Bar --------------- //
|
// --------------- Bottom & Top Bar --------------- //
|
||||||
// ------------------------------------------------ //
|
// ------------------------------------------------ //
|
||||||
renderDoc.addEventListener(
|
let emSize = parseFloat(getComputedStyle(renderDoc.body).fontSize);
|
||||||
"click",
|
renderDoc.addEventListener("click", function (event) {
|
||||||
function (event) {
|
let barPixels = emSize * 5;
|
||||||
// Get Window Dimensions
|
|
||||||
let windowWidth = window.innerWidth;
|
|
||||||
let windowHeight = window.innerHeight;
|
|
||||||
|
|
||||||
// Calculate X & Y Hot Zones
|
let top = barPixels;
|
||||||
let barPixels = windowHeight * 0.2;
|
let bottom = window.innerHeight - top;
|
||||||
let pagePixels = windowWidth * 0.2;
|
|
||||||
|
|
||||||
// Calculate Top & Bottom Thresholds
|
let left = barPixels / 2;
|
||||||
let top = barPixels;
|
let right = window.innerWidth - left;
|
||||||
let bottom = window.innerHeight - top;
|
|
||||||
|
|
||||||
// Calculate Left & Right Thresholds
|
if (event.clientY < top) handleSwipeDown();
|
||||||
let left = pagePixels;
|
else if (event.clientY > bottom) handleSwipeUp();
|
||||||
let right = windowWidth - left;
|
else if (event.screenX < left) prevPage();
|
||||||
|
else if (event.screenX > right) nextPage();
|
||||||
// Calculate Relative Coords
|
else {
|
||||||
let leftOffset = this.views().container.scrollLeft;
|
bottomBar.classList.remove("bottom-0");
|
||||||
let yCoord = event.clientY;
|
topBar.classList.remove("top-0");
|
||||||
let xCoord = event.clientX - leftOffset;
|
}
|
||||||
|
});
|
||||||
// Handle Event
|
|
||||||
if (yCoord < top) handleSwipeDown();
|
|
||||||
else if (yCoord > bottom) handleSwipeUp();
|
|
||||||
else if (xCoord < left) prevPage();
|
|
||||||
else if (xCoord > right) nextPage();
|
|
||||||
else {
|
|
||||||
bottomBar.classList.remove("bottom-0");
|
|
||||||
topBar.classList.remove("top-0");
|
|
||||||
}
|
|
||||||
}.bind(this)
|
|
||||||
);
|
|
||||||
|
|
||||||
renderDoc.addEventListener(
|
renderDoc.addEventListener(
|
||||||
"wheel",
|
"wheel",
|
||||||
@ -575,6 +506,7 @@ class EBookReader {
|
|||||||
"click",
|
"click",
|
||||||
function (event) {
|
function (event) {
|
||||||
let colorScheme = event.target.innerText;
|
let colorScheme = event.target.innerText;
|
||||||
|
console.log(colorScheme);
|
||||||
this.setTheme({ colorScheme });
|
this.setTheme({ colorScheme });
|
||||||
}.bind(this)
|
}.bind(this)
|
||||||
);
|
);
|
||||||
@ -633,8 +565,26 @@ class EBookReader {
|
|||||||
* Progresses to the next page & monitors reading activity
|
* Progresses to the next page & monitors reading activity
|
||||||
**/
|
**/
|
||||||
async nextPage() {
|
async nextPage() {
|
||||||
// Create Activity
|
// Flush Activity
|
||||||
await this.createActivity();
|
this.flushActivity();
|
||||||
|
|
||||||
|
// Get Elapsed Time
|
||||||
|
let elapsedTime = Date.now() - this.bookState.pageStart;
|
||||||
|
|
||||||
|
// Update Current Word
|
||||||
|
let pageWords = await this.getVisibleWordCount();
|
||||||
|
let startingWord = this.bookState.currentWord;
|
||||||
|
let percentRead = pageWords / this.bookState.words;
|
||||||
|
this.bookState.currentWord += pageWords;
|
||||||
|
|
||||||
|
// Add Read Event
|
||||||
|
this.bookState.readActivity.push({
|
||||||
|
percentRead,
|
||||||
|
startingWord,
|
||||||
|
pageWords,
|
||||||
|
elapsedTime,
|
||||||
|
startTime: this.bookState.pageStart,
|
||||||
|
});
|
||||||
|
|
||||||
// Render Next Page
|
// Render Next Page
|
||||||
await this.rendition.next();
|
await this.rendition.next();
|
||||||
@ -644,16 +594,24 @@ class EBookReader {
|
|||||||
|
|
||||||
// Update Stats
|
// Update Stats
|
||||||
let stats = this.getBookStats();
|
let stats = this.getBookStats();
|
||||||
this.updateBookStatElements(stats);
|
this.updateBookStats(stats);
|
||||||
|
|
||||||
// Create Progress
|
// Update & Flush Progress
|
||||||
this.createProgress();
|
let currentCFI = await this.rendition.currentLocation();
|
||||||
|
let { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
|
||||||
|
this.bookState.progress = xpath;
|
||||||
|
this.bookState.progressElement = element;
|
||||||
|
|
||||||
|
this.flushProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Progresses to the previous page & monitors reading activity
|
* Progresses to the previous page & monitors reading activity
|
||||||
**/
|
**/
|
||||||
async prevPage() {
|
async prevPage() {
|
||||||
|
// Flush Activity
|
||||||
|
this.flushActivity();
|
||||||
|
|
||||||
// Render Previous Page
|
// Render Previous Page
|
||||||
await this.rendition.prev();
|
await this.rendition.prev();
|
||||||
|
|
||||||
@ -666,10 +624,14 @@ class EBookReader {
|
|||||||
|
|
||||||
// Update Stats
|
// Update Stats
|
||||||
let stats = this.getBookStats();
|
let stats = this.getBookStats();
|
||||||
this.updateBookStatElements(stats);
|
this.updateBookStats(stats);
|
||||||
|
|
||||||
// Create Progress
|
// Update & Flush Progress
|
||||||
this.createProgress();
|
let currentCFI = await this.rendition.currentLocation();
|
||||||
|
let { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
|
||||||
|
this.bookState.progress = xpath;
|
||||||
|
this.bookState.progressElement = element;
|
||||||
|
this.flushProgress();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -690,95 +652,88 @@ class EBookReader {
|
|||||||
this.highlightPositionMarker();
|
this.highlightPositionMarker();
|
||||||
}
|
}
|
||||||
|
|
||||||
async createActivity() {
|
/**
|
||||||
// WPM MAX & MIN
|
* Normalize and flush activity
|
||||||
|
**/
|
||||||
|
async flushActivity() {
|
||||||
|
// Process & Reset Activity
|
||||||
|
let allActivity = this.bookState.readActivity;
|
||||||
|
this.bookState.readActivity = [];
|
||||||
|
|
||||||
const WPM_MAX = 2000;
|
const WPM_MAX = 2000;
|
||||||
const WPM_MIN = 100;
|
const WPM_MIN = 100;
|
||||||
|
|
||||||
// Get Elapsed Time
|
let normalizedActivity = allActivity
|
||||||
let pageStart = this.bookState.pageStart;
|
// Exclude Fast WPM
|
||||||
let elapsedTime = Date.now() - pageStart;
|
.filter((item) => item.pageWords / (item.elapsedTime / 60000) < WPM_MAX)
|
||||||
|
.map((item) => {
|
||||||
|
let pageWPM = item.pageWords / (item.elapsedTime / 60000);
|
||||||
|
|
||||||
// Update Current Word
|
// Min WPM
|
||||||
let pageWords = await this.getVisibleWordCount();
|
if (pageWPM < WPM_MIN) {
|
||||||
let startingWord = this.bookState.currentWord;
|
// TODO - Exclude Event?
|
||||||
let percentRead = pageWords / this.bookState.words;
|
item.elapsedTime = (item.pageWords / WPM_MIN) * 60000;
|
||||||
this.bookState.currentWord += pageWords;
|
}
|
||||||
|
|
||||||
let pageWPM = pageWords / (elapsedTime / 60000);
|
item.pages = Math.round(1 / item.percentRead);
|
||||||
|
|
||||||
// Exclude Ridiculous WPM
|
item.page = Math.round(
|
||||||
// if (pageWPM >= WPM_MAX) return;
|
(item.startingWord * item.pages) / this.bookState.words
|
||||||
|
);
|
||||||
|
|
||||||
// Ensure WPM Minimum
|
// Estimate Accuracy Loss (Debugging)
|
||||||
if (pageWPM < WPM_MIN) elapsedTime = (pageWords / WPM_MIN) * 60000;
|
// let wordLoss = Math.abs(
|
||||||
|
// item.pageWords - this.bookState.words / item.pages
|
||||||
|
// );
|
||||||
|
// console.log("Word Loss:", wordLoss);
|
||||||
|
|
||||||
let totalPages = Math.round(1 / percentRead);
|
return {
|
||||||
|
document: this.bookState.id,
|
||||||
|
duration: Math.round(item.elapsedTime / 1000),
|
||||||
|
start_time: Math.round(item.startTime / 1000),
|
||||||
|
page: item.page,
|
||||||
|
pages: item.pages,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
let currentPage = Math.round(
|
if (normalizedActivity.length == 0) return;
|
||||||
(startingWord * totalPages) / this.bookState.words
|
|
||||||
);
|
console.log("Flushing Activity...");
|
||||||
|
|
||||||
// Create Activity Event
|
// Create Activity Event
|
||||||
let activityEvent = {
|
let activityEvent = {
|
||||||
device_id: this.readerSettings.deviceID,
|
device_id: this.readerSettings.deviceID,
|
||||||
device: this.readerSettings.deviceName,
|
device: this.readerSettings.deviceName,
|
||||||
activity: [
|
activity: normalizedActivity,
|
||||||
{
|
|
||||||
document: this.bookState.id,
|
|
||||||
duration: Math.round(elapsedTime / 1000),
|
|
||||||
start_time: Math.round(pageStart / 1000),
|
|
||||||
page: currentPage,
|
|
||||||
pages: totalPages,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Flush -> Offline Cache IDB
|
// Flush Activity
|
||||||
this.flushActivity(activityEvent).catch(async (e) => {
|
fetch("/api/ko/activity", {
|
||||||
console.error("[createActivity] Activity Flush Failed:", {
|
method: "POST",
|
||||||
error: e,
|
body: JSON.stringify(activityEvent),
|
||||||
data: activityEvent,
|
})
|
||||||
});
|
.then(async (r) =>
|
||||||
|
console.log("Flushed Activity:", {
|
||||||
// Get & Update Activity
|
response: r,
|
||||||
let existingActivity = await IDB.get("ACTIVITY", { activity: [] });
|
json: await r.json(),
|
||||||
existingActivity.device_id = activityEvent.device_id;
|
data: activityEvent,
|
||||||
existingActivity.device = activityEvent.device;
|
})
|
||||||
existingActivity.activity.push(...activityEvent.activity);
|
)
|
||||||
|
.catch((e) =>
|
||||||
// Update IDB
|
console.error("Activity Flush Failed:", {
|
||||||
await IDB.set("ACTIVITY", existingActivity);
|
error: e,
|
||||||
});
|
data: activityEvent,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize and flush activity
|
* Flush progress to the API. Called when the page changes.
|
||||||
**/
|
**/
|
||||||
flushActivity(activityEvent) {
|
async flushProgress() {
|
||||||
console.log("[flushActivity] Flushing Activity...");
|
console.log("Flushing Progress...");
|
||||||
|
|
||||||
// Flush Activity
|
// Create Progress Event
|
||||||
return fetch("/api/ko/activity", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(activityEvent),
|
|
||||||
}).then(async (r) =>
|
|
||||||
console.log("[flushActivity] Flushed Activity:", {
|
|
||||||
response: r,
|
|
||||||
json: await r.json(),
|
|
||||||
data: activityEvent,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createProgress() {
|
|
||||||
// Update Pointers
|
|
||||||
let currentCFI = await this.rendition.currentLocation();
|
|
||||||
let { element, xpath } = await this.getXPathFromCFI(currentCFI.start.cfi);
|
|
||||||
this.bookState.progress = xpath;
|
|
||||||
this.bookState.progressElement = element;
|
|
||||||
|
|
||||||
// Create Event
|
|
||||||
let progressEvent = {
|
let progressEvent = {
|
||||||
document: this.bookState.id,
|
document: this.bookState.id,
|
||||||
device_id: this.readerSettings.deviceID,
|
device_id: this.readerSettings.deviceID,
|
||||||
@ -790,35 +745,24 @@ class EBookReader {
|
|||||||
progress: this.bookState.progress,
|
progress: this.bookState.progress,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Flush -> Offline Cache IDB
|
|
||||||
this.flushProgress(progressEvent).catch(async (e) => {
|
|
||||||
console.error("[createProgress] Progress Flush Failed:", {
|
|
||||||
error: e,
|
|
||||||
data: progressEvent,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update IDB
|
|
||||||
await IDB.set("PROGRESS-" + progressEvent.document, progressEvent);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flush progress to the API. Called when the page changes.
|
|
||||||
**/
|
|
||||||
flushProgress(progressEvent) {
|
|
||||||
console.log("[flushProgress] Flushing Progress...");
|
|
||||||
|
|
||||||
// Flush Progress
|
// Flush Progress
|
||||||
return fetch("/api/ko/syncs/progress", {
|
fetch("/api/ko/syncs/progress", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(progressEvent),
|
body: JSON.stringify(progressEvent),
|
||||||
}).then(async (r) =>
|
})
|
||||||
console.log("[flushProgress] Flushed Progress:", {
|
.then(async (r) =>
|
||||||
response: r,
|
console.log("Flushed Progress:", {
|
||||||
json: await r.json(),
|
response: r,
|
||||||
data: progressEvent,
|
json: await r.json(),
|
||||||
})
|
data: progressEvent,
|
||||||
);
|
})
|
||||||
|
)
|
||||||
|
.catch((e) =>
|
||||||
|
console.error("Progress Flush Failed:", {
|
||||||
|
error: e,
|
||||||
|
data: progressEvent,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -826,8 +770,7 @@ class EBookReader {
|
|||||||
**/
|
**/
|
||||||
sectionProgress() {
|
sectionProgress() {
|
||||||
let visibleItems = this.rendition.manager.visible();
|
let visibleItems = this.rendition.manager.visible();
|
||||||
if (visibleItems.length == 0)
|
if (visibleItems.length == 0) return console.log("No Items");
|
||||||
return console.log("[sectionProgress] No Items");
|
|
||||||
let visibleSection = visibleItems[0];
|
let visibleSection = visibleItems[0];
|
||||||
let visibleIndex = visibleSection.index;
|
let visibleIndex = visibleSection.index;
|
||||||
let pagesPerBlock = visibleSection.layout.divisor;
|
let pagesPerBlock = visibleSection.layout.divisor;
|
||||||
@ -869,7 +812,7 @@ class EBookReader {
|
|||||||
/**
|
/**
|
||||||
* Update elements with stats
|
* Update elements with stats
|
||||||
**/
|
**/
|
||||||
updateBookStatElements(data) {
|
updateBookStats(data) {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
let chapterStatus = document.querySelector("#chapter-status");
|
let chapterStatus = document.querySelector("#chapter-status");
|
||||||
@ -1133,5 +1076,3 @@ class EBookReader {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", initReader);
|
|
||||||
|
@ -2046,11 +2046,6 @@ video {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.lg\:mx-48 {
|
|
||||||
margin-left: 12rem;
|
|
||||||
margin-right: 12rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.lg\:ml-44 {
|
.lg\:ml-44 {
|
||||||
margin-left: 11rem;
|
margin-left: 11rem;
|
||||||
}
|
}
|
||||||
|
222
assets/sw.js
@ -1,222 +0,0 @@
|
|||||||
// Misc Consts
|
|
||||||
const SW_VERSION = 1;
|
|
||||||
const SW_CACHE_NAME = "OFFLINE_V1";
|
|
||||||
|
|
||||||
// Message Types
|
|
||||||
const PURGE_SW_CACHE = "PURGE_SW_CACHE";
|
|
||||||
const DEL_SW_CACHE = "DEL_SW_CACHE";
|
|
||||||
const GET_SW_CACHE = "GET_SW_CACHE";
|
|
||||||
const GET_SW_VERSION = "GET_SW_VERSION";
|
|
||||||
|
|
||||||
// Cache Types
|
|
||||||
const CACHE_ONLY = "CACHE_ONLY";
|
|
||||||
const CACHE_NEVER = "CACHE_NEVER";
|
|
||||||
const CACHE_UPDATE_SYNC = "CACHE_UPDATE_SYNC";
|
|
||||||
const CACHE_UPDATE_ASYNC = "CACHE_UPDATE_ASYNC";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Define routes and their directives. Takes `routes`, `type`, and `fallback`.
|
|
||||||
*
|
|
||||||
* Routes (Required):
|
|
||||||
* Either a string of the exact request, or a RegExp. Order precedence.
|
|
||||||
*
|
|
||||||
* Fallback (Optional):
|
|
||||||
* A fallback function. If the request fails, this function is executed and
|
|
||||||
* its return value is returned as the result.
|
|
||||||
*
|
|
||||||
* Types (Required):
|
|
||||||
* - CACHE_ONLY
|
|
||||||
* Cache once & never refresh.
|
|
||||||
* - CACHE_NEVER
|
|
||||||
* Never cache & always perform a request.
|
|
||||||
* - CACHE_UPDATE_SYNC
|
|
||||||
* Update cache & return result.
|
|
||||||
* - CACHE_UPDATE_ASYNC
|
|
||||||
* Return cache if exists & update cache in background.
|
|
||||||
**/
|
|
||||||
const ROUTES = [
|
|
||||||
{ route: "/offline", type: CACHE_UPDATE_ASYNC },
|
|
||||||
{ route: "/reader", type: CACHE_UPDATE_ASYNC },
|
|
||||||
{ route: "/manifest.json", type: CACHE_UPDATE_ASYNC },
|
|
||||||
{ route: /^\/assets\//, type: CACHE_UPDATE_ASYNC },
|
|
||||||
{
|
|
||||||
route: /^\/documents\/[a-zA-Z0-9]{32}\/(cover|file)$/,
|
|
||||||
type: CACHE_UPDATE_ASYNC,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
route: /^\/documents\/[a-zA-Z0-9]{32}\/progress$/,
|
|
||||||
type: CACHE_UPDATE_SYNC,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
route: /.*/,
|
|
||||||
type: CACHE_NEVER,
|
|
||||||
fallback: (event) => caches.match("/offline"),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* These are assets that are cached on initial service worker installation.
|
|
||||||
**/
|
|
||||||
const PRECACHE_ASSETS = [
|
|
||||||
// Offline & Reqder Assets
|
|
||||||
"/offline",
|
|
||||||
"/reader",
|
|
||||||
"/assets/offline/index.js",
|
|
||||||
"/assets/reader/index.js",
|
|
||||||
"/assets/images/no-cover.jpg",
|
|
||||||
"/assets/reader/readerThemes.css",
|
|
||||||
|
|
||||||
// Main App Assets
|
|
||||||
"/manifest.json",
|
|
||||||
"/assets/index.js",
|
|
||||||
"/assets/style.css",
|
|
||||||
|
|
||||||
// Library Assets
|
|
||||||
"/assets/lib/platform.js",
|
|
||||||
"/assets/lib/jszip.min.js",
|
|
||||||
"/assets/lib/epub.min.js",
|
|
||||||
"/assets/lib/no-sleep.js",
|
|
||||||
"/assets/lib/idb-keyval.js",
|
|
||||||
"/assets/lib/sw-helper.js",
|
|
||||||
];
|
|
||||||
|
|
||||||
// ------------------------------------------------------- //
|
|
||||||
// ----------------------- Helpers ----------------------- //
|
|
||||||
// ------------------------------------------------------- //
|
|
||||||
|
|
||||||
function purgeCache() {
|
|
||||||
console.log("[purgeCache] Purging Cache");
|
|
||||||
return caches.keys().then(function (names) {
|
|
||||||
for (let name of names) caches.delete(name);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateCache(request) {
|
|
||||||
let url = request.url ? new URL(request.url).pathname : request;
|
|
||||||
console.log("[updateCache] Updating Cache:", url);
|
|
||||||
|
|
||||||
let cache = await caches.open(SW_CACHE_NAME);
|
|
||||||
|
|
||||||
return fetch(request)
|
|
||||||
.then((response) => {
|
|
||||||
const resClone = response.clone();
|
|
||||||
if (response.status < 400) cache.put(request, resClone);
|
|
||||||
return response;
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.log("[updateCache] Updating Cache Failed:", url);
|
|
||||||
throw e;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------- //
|
|
||||||
// ------------------- Event Listeners ------------------- //
|
|
||||||
// ------------------------------------------------------- //
|
|
||||||
|
|
||||||
async function handleFetch(event) {
|
|
||||||
// Get Path
|
|
||||||
let url = new URL(event.request.url).pathname;
|
|
||||||
|
|
||||||
// Find Directive
|
|
||||||
const directive = ROUTES.find(
|
|
||||||
(item) => url.match(item.route) || url == item.route
|
|
||||||
) || { type: CACHE_NEVER };
|
|
||||||
|
|
||||||
// Get Fallback
|
|
||||||
const fallbackFunc = (event) => {
|
|
||||||
console.log("[handleFetch] Fallback:", { url, directive });
|
|
||||||
if (directive.fallback) return directive.fallback(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("[handleFetch] Processing:", { url, directive });
|
|
||||||
|
|
||||||
// Get Current Cache
|
|
||||||
let currentCache = await caches.match(event.request);
|
|
||||||
|
|
||||||
// Perform Caching Method
|
|
||||||
switch (directive.type) {
|
|
||||||
case CACHE_NEVER:
|
|
||||||
return fetch(event.request).catch((e) => fallbackFunc(event));
|
|
||||||
case CACHE_ONLY:
|
|
||||||
return (
|
|
||||||
currentCache ||
|
|
||||||
updateCache(event.request).catch((e) => fallbackFunc(event))
|
|
||||||
);
|
|
||||||
case CACHE_UPDATE_SYNC:
|
|
||||||
return updateCache(event.request).catch(
|
|
||||||
(e) => currentCache || fallbackFunc(event)
|
|
||||||
);
|
|
||||||
case CACHE_UPDATE_ASYNC:
|
|
||||||
let newResponse = updateCache(event.request).catch((e) =>
|
|
||||||
fallbackFunc(event)
|
|
||||||
);
|
|
||||||
|
|
||||||
return currentCache || newResponse;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMessage(event) {
|
|
||||||
console.log("[handleMessage] Received Message:", event.data);
|
|
||||||
let { id, data } = event.data;
|
|
||||||
|
|
||||||
if (data.type === GET_SW_VERSION) {
|
|
||||||
event.source.postMessage({ id, data: SW_VERSION });
|
|
||||||
} else if (data.type === PURGE_SW_CACHE) {
|
|
||||||
purgeCache()
|
|
||||||
.then(() => event.source.postMessage({ id, data: "SUCCESS" }))
|
|
||||||
.catch(() => event.source.postMessage({ id, data: "FAILURE" }));
|
|
||||||
} else if (data.type === GET_SW_CACHE) {
|
|
||||||
caches.open(SW_CACHE_NAME).then(async (cache) => {
|
|
||||||
let allKeys = await cache.keys();
|
|
||||||
|
|
||||||
let docResources = allKeys
|
|
||||||
.map((item) => new URL(item.url).pathname)
|
|
||||||
.filter((item) => item.startsWith("/documents/"));
|
|
||||||
|
|
||||||
let documentIDs = Array.from(
|
|
||||||
new Set(docResources.map((item) => item.split("/")[2]))
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filter for cached items only. Attempt to fetch updated result. If
|
|
||||||
* failure, return cached version. This ensures we return the most up to
|
|
||||||
* date version possible.
|
|
||||||
**/
|
|
||||||
let cachedDocuments = await Promise.all(
|
|
||||||
documentIDs
|
|
||||||
.filter(
|
|
||||||
(id) =>
|
|
||||||
docResources.includes("/documents/" + id + "/file") &&
|
|
||||||
docResources.includes("/documents/" + id + "/progress")
|
|
||||||
)
|
|
||||||
.map(async (id) => {
|
|
||||||
let url = "/documents/" + id + "/progress";
|
|
||||||
let currentCache = await caches.match(url);
|
|
||||||
let resp = await updateCache(url).catch((e) => currentCache);
|
|
||||||
return resp.json();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
event.source.postMessage({ id, data: cachedDocuments });
|
|
||||||
});
|
|
||||||
} else if (data.type === DEL_SW_CACHE) {
|
|
||||||
// TODO
|
|
||||||
} else {
|
|
||||||
event.source.postMessage({ id, data: { pong: 1 } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleInstall(event) {
|
|
||||||
let cache = await caches.open(SW_CACHE_NAME);
|
|
||||||
return cache.addAll(PRECACHE_ASSETS);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.addEventListener("message", handleMessage);
|
|
||||||
|
|
||||||
self.addEventListener("install", function (event) {
|
|
||||||
event.waitUntil(handleInstall(event));
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) =>
|
|
||||||
event.respondWith(handleFetch(event))
|
|
||||||
);
|
|
@ -70,9 +70,9 @@ local STATISTICS_ACTIVITY_SINCE_QUERY = [[
|
|||||||
psd.start_time AS start_time,
|
psd.start_time AS start_time,
|
||||||
psd.duration AS duration,
|
psd.duration AS duration,
|
||||||
psd.page AS current_page,
|
psd.page AS current_page,
|
||||||
psd.total_pages
|
psd.total_pages
|
||||||
FROM page_stat_data AS psd
|
FROM page_stat_data AS psd
|
||||||
JOIN book AS b
|
JOIN book AS b
|
||||||
ON b.id = psd.id_book
|
ON b.id = psd.id_book
|
||||||
WHERE start_time > %d
|
WHERE start_time > %d
|
||||||
ORDER BY start_time ASC LIMIT 5000;
|
ORDER BY start_time ASC LIMIT 5000;
|
||||||
@ -649,7 +649,7 @@ end
|
|||||||
function SyncNinja:checkDocuments(interactive)
|
function SyncNinja:checkDocuments(interactive)
|
||||||
logger.dbg("SyncNinja: checkDocuments")
|
logger.dbg("SyncNinja: checkDocuments")
|
||||||
|
|
||||||
-- Ensure Document Sync Enabled
|
-- ensure document sync enabled
|
||||||
if self.settings.sync_documents ~= true then return end
|
if self.settings.sync_documents ~= true then return end
|
||||||
|
|
||||||
-- API Request Data
|
-- API Request Data
|
||||||
@ -723,8 +723,6 @@ function SyncNinja:downloadDocuments(doc_metadata, interactive)
|
|||||||
logger.dbg("SyncNinja: downloadDocuments")
|
logger.dbg("SyncNinja: downloadDocuments")
|
||||||
|
|
||||||
-- TODO
|
-- TODO
|
||||||
-- - OPDS Sufficient?
|
|
||||||
-- - Auto Configure OPDS?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function SyncNinja:uploadDocumentMetadata(doc_metadata, interactive)
|
function SyncNinja:uploadDocumentMetadata(doc_metadata, interactive)
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: [
|
content: ["./templates/**/*.html", "./assets/reader/index.js"],
|
||||||
"./templates/**/*.html",
|
|
||||||
"./assets/offline/*.{html,js}",
|
|
||||||
"./assets/reader/*.{html,js}",
|
|
||||||
],
|
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {},
|
||||||
},
|
},
|
||||||
|
@ -14,11 +14,6 @@
|
|||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<link rel="stylesheet" href="/assets/style.css">
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
|
||||||
<!-- Service Worker / Offline Cache Flush -->
|
|
||||||
<script src="/assets/lib/idb-keyval.js"></script>
|
|
||||||
<script src="/assets/lib/sw-helper.js"></script>
|
|
||||||
<script src="/assets/index.js"></script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ----------------------------- */
|
/* ----------------------------- */
|
||||||
/* -------- PWA Styling -------- */
|
/* -------- PWA Styling -------- */
|
||||||
@ -103,7 +98,7 @@
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
#mobile-nav-button input ~ div {
|
#mobile-nav-button input ~ div {
|
||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
@ -37,7 +37,7 @@
|
|||||||
>
|
>
|
||||||
<p class="font-medium w-24 pb-2">Are you sure?</p>
|
<p class="font-medium w-24 pb-2">Are you sure?</p>
|
||||||
<button class="font-medium w-full px-2 py-1 text-white bg-gray-500 dark:text-gray-800 dark:bg-gray-400 hover:bg-gray-800 dark:hover:bg-gray-100" type="submit">Delete</button>
|
<button class="font-medium w-full px-2 py-1 text-white bg-gray-500 dark:text-gray-800 dark:bg-gray-400 hover:bg-gray-800 dark:hover:bg-gray-100" type="submit">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<a href="../activity?document={{ .Data.ID }}">
|
<a href="../activity?document={{ .Data.ID }}">
|
||||||
<svg
|
<svg
|
||||||
@ -83,7 +83,7 @@
|
|||||||
<label class="font-medium" for="isbn">ISBN</label>
|
<label class="font-medium" for="isbn">ISBN</label>
|
||||||
<input class="mt-1 mb-2 p-1 bg-gray-400 text-black dark:bg-gray-700 dark:text-white" type="text" id="isbn" name="ISBN"><br>
|
<input class="mt-1 mb-2 p-1 bg-gray-400 text-black dark:bg-gray-700 dark:text-white" type="text" id="isbn" name="ISBN"><br>
|
||||||
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 dark:bg-gray-400 hover:bg-gray-800 dark:hover:bg-gray-100" type="submit">Search Metadata</button>
|
<button class="font-medium px-2 py-1 text-white bg-gray-500 dark:text-gray-800 dark:bg-gray-400 hover:bg-gray-800 dark:hover:bg-gray-100" type="submit">Search Metadata</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{{ if .Data.Filepath }}
|
{{ if .Data.Filepath }}
|
||||||
<a href="./{{.Data.ID}}/file">
|
<a href="./{{.Data.ID}}/file">
|
||||||
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
{{ if .Data.Filepath }}
|
{{ if .Data.Filepath }}
|
||||||
<a
|
<a
|
||||||
href="/reader#id={{ .Data.ID }}"
|
href="./{{ .Data.ID }}/reader"
|
||||||
class="z-10 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
class="z-10 text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded text-sm text-center py-1 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
||||||
>Read</a>
|
>Read</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
@ -26,11 +26,6 @@
|
|||||||
|
|
||||||
<link rel="manifest" href="./manifest.json" />
|
<link rel="manifest" href="./manifest.json" />
|
||||||
<link rel="stylesheet" href="./assets/style.css" />
|
<link rel="stylesheet" href="./assets/style.css" />
|
||||||
|
|
||||||
<!-- Service Worker / Offline Cache Flush -->
|
|
||||||
<script src="/assets/lib/idb-keyval.js"></script>
|
|
||||||
<script src="/assets/lib/sw-helper.js"></script>
|
|
||||||
<script src="/assets/index.js"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100 dark:bg-gray-800 dark:text-white">
|
<body class="bg-gray-100 dark:bg-gray-800 dark:text-white">
|
||||||
<div class="flex flex-wrap w-full">
|
<div class="flex flex-wrap w-full">
|
||||||
@ -139,19 +134,19 @@
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
||||||
src="/assets/images/book1.jpg"
|
src="/assets/book1.jpg"
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
||||||
src="/assets/images/book2.jpg"
|
src="/assets/book2.jpg"
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
||||||
src="/assets/images/book3.jpg"
|
src="/assets/book3.jpg"
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
class="w-full h-screen object-cover ease-in-out top-0 left-0"
|
||||||
src="/assets/images/book4.jpg"
|
src="/assets/book4.jpg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,23 +14,11 @@
|
|||||||
/>
|
/>
|
||||||
<meta name="theme-color" content="#D2B48C" />
|
<meta name="theme-color" content="#D2B48C" />
|
||||||
|
|
||||||
<title>Book Manager - Reader</title>
|
<title>Book Manager - {{block "title" .}}{{end}}</title>
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<link rel="stylesheet" href="/assets/style.css" />
|
<link rel="stylesheet" href="/assets/style.css" />
|
||||||
|
|
||||||
<!-- Libraries -->
|
|
||||||
<script src="/assets/lib/platform.js"></script>
|
|
||||||
<script src="/assets/lib/jszip.min.js"></script>
|
|
||||||
<script src="/assets/lib/epub.min.js"></script>
|
|
||||||
<script src="/assets/lib/no-sleep.js"></script>
|
|
||||||
<script src="/assets/lib/idb-keyval.js"></script>
|
|
||||||
<script src="/assets/lib/sw-helper.js"></script>
|
|
||||||
|
|
||||||
<!-- Reader -->
|
|
||||||
<script src="/assets/index.js"></script>
|
|
||||||
<script src="/assets/reader/index.js"></script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
@ -78,7 +66,7 @@
|
|||||||
>
|
>
|
||||||
<div class="w-full h-32 flex items-center justify-around relative">
|
<div class="w-full h-32 flex items-center justify-around relative">
|
||||||
<div class="text-gray-500 absolute top-6 left-4 flex flex-col gap-4">
|
<div class="text-gray-500 absolute top-6 left-4 flex flex-col gap-4">
|
||||||
<a href="#">
|
<a href="../{{ .Data.ID }}">
|
||||||
<svg
|
<svg
|
||||||
width="32"
|
width="32"
|
||||||
height="32"
|
height="32"
|
||||||
@ -113,11 +101,8 @@
|
|||||||
|
|
||||||
<div class="flex gap-10 h-full p-4 pl-14 rounded">
|
<div class="flex gap-10 h-full p-4 pl-14 rounded">
|
||||||
<div class="h-full my-auto relative">
|
<div class="h-full my-auto relative">
|
||||||
<a href="#">
|
<a href="../{{ .Data.ID }}">
|
||||||
<img
|
<img class="rounded object-cover h-full" src="./cover" />
|
||||||
class="rounded object-cover h-full"
|
|
||||||
src="/assets/images/no-cover.jpg"
|
|
||||||
/>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-7 justify-around dark:text-white text-sm">
|
<div class="flex gap-7 justify-around dark:text-white text-sm">
|
||||||
@ -128,7 +113,7 @@
|
|||||||
<p
|
<p
|
||||||
class="font-medium whitespace-nowrap text-ellipsis overflow-hidden max-w-[50dvw]"
|
class="font-medium whitespace-nowrap text-ellipsis overflow-hidden max-w-[50dvw]"
|
||||||
>
|
>
|
||||||
"N/A"
|
{{ or .Data.Title "N/A" }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -138,7 +123,7 @@
|
|||||||
<p
|
<p
|
||||||
class="font-medium whitespace-nowrap text-ellipsis overflow-hidden max-w-[50dvw]"
|
class="font-medium whitespace-nowrap text-ellipsis overflow-hidden max-w-[50dvw]"
|
||||||
>
|
>
|
||||||
"N/A"
|
{{ or .Data.Author "N/A" }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -255,7 +240,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="viewer" class="w-full h-full"></div>
|
{{block "content" .}}{{end}}
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
22
templates/reader.html
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{{template "base.html" .}} {{define "title"}}Reader{{end}} {{define "header"}}
|
||||||
|
<a href="../">Documents</a>
|
||||||
|
{{end}} {{define "content"}}
|
||||||
|
|
||||||
|
<div id="viewer" class="w-full h-full"></div>
|
||||||
|
|
||||||
|
<script src="../../assets/reader/platform.js"></script>
|
||||||
|
<script src="../../assets/reader/jszip.min.js"></script>
|
||||||
|
<script src="../../assets/reader/epub.min.js"></script>
|
||||||
|
<script src="../../assets/reader/no-sleep.js"></script>
|
||||||
|
<script src="../../assets/reader/index.js"></script>
|
||||||
|
<script>
|
||||||
|
let currentReader = new EBookReader("./file", {
|
||||||
|
id: "{{ .Data.ID }}",
|
||||||
|
words: {{ .Data.Words }},
|
||||||
|
pages: {{ .Data.Pages }},
|
||||||
|
progress: "{{ .Progress }}",
|
||||||
|
percentage: {{ .Data.Percentage }},
|
||||||
|
currentWord: {{ .Data.Percentage }} * ({{ .Data.Words }} / 100),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{ end}}
|