[add] wip service worker & offline reader
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Evan Reichard 2023-10-28 20:07:24 -04:00
parent 1b8b5060f1
commit 2c90377cf7
27 changed files with 856 additions and 203 deletions

View File

@ -42,8 +42,9 @@ In additional to the compatible KOSync API's, we add:
- Additional APIs to automatically upload reading statistics
- 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))
- No JavaScript for the main app! All information is generated server side with go templates.
- JavaScript is used for the ePub reader. Goals to make it service worker to enable a complete offline PWA reading experience.
- Limited JavaScript use. Server-Side Rendering is used wherever possible. The main app is fully operational without any JS. JS is only required for:
- EPUB Reader
- Offline Mode / Service Worker
# Server
@ -77,10 +78,9 @@ The service is now accessible at: `http://localhost:8585`. I recommend registeri
## Configuration
| Environment Variable | Default Value | Description |
| -------------------- | ------------- | -------------------------------------------------------------------- |
| -------------------- | ------------- | ------------------------------------------------------------------- |
| DATABASE_TYPE | SQLite | Currently only "SQLite" is supported |
| DATABASE_NAME | bbank | The database name, or in SQLite's case, the filename |
| DATABASE_PASSWORD | <EMPTY> | Currently not used. Placeholder for potential alternative DB support |
| DATABASE_NAME | book_manager | The database name, or in SQLite's case, the filename |
| CONFIG_PATH | /config | Directory where to store SQLite's DB |
| DATA_PATH | /data | Directory where to store the documents and cover metadata |
| LISTEN_PORT | 8585 | Port the server listens at |
@ -94,7 +94,7 @@ The service is now accessible at: `http://localhost:8585`. I recommend registeri
### Authentication
- _Web App / PWA_ - Session based token (7 day expiry, refresh after 6 days)
- _KOSync & SyncNinja API_ - Header based (KOSync compatibility)
- _KOSync & SyncNinja API_ - Header based - `X-Auth-User` & `X-Auth-Key` (KOSync compatibility)
- _OPDS API_ - Basic authentication (KOReader OPDS compatibility)
### Notes
@ -119,7 +119,7 @@ go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
Run Development:
```bash
CONFIG_PATH=./data DATA_PATH=./data go run main.go serve
CONFIG_PATH=./data DATA_PATH=./data REGISTRATION_ENABLED=true go run main.go serve
```
# Building
@ -136,6 +136,16 @@ make docker_build_local
# Build Docker & Push Latest or Dev (Linux - arm64 & amd64)
make docker_build_release_latest
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

View File

@ -80,7 +80,6 @@ func (api *API) registerWebAppRoutes() {
render.AddFromFiles("error", "templates/error.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("search", helperFuncs, "templates/base.html", "templates/search.html")
render.AddFromFilesFuncs("settings", helperFuncs, "templates/base.html", "templates/settings.html")
@ -90,7 +89,15 @@ func (api *API) registerWebAppRoutes() {
api.Router.HTMLRender = render
// Static Assets (Require @ Root)
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("/register", api.createAppResourcesRoute("login", gin.H{"Register": true}))
api.Router.GET("/logout", api.authWebAppMiddleware, api.authLogout)
@ -104,12 +111,12 @@ func (api *API) registerWebAppRoutes() {
api.Router.GET("/documents", api.authWebAppMiddleware, api.createAppResourcesRoute("documents"))
api.Router.POST("/documents", api.authWebAppMiddleware, api.uploadNewDocument)
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/cover", api.authWebAppMiddleware, api.getDocumentCover)
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/delete", api.authWebAppMiddleware, api.deleteDocument)
api.Router.GET("/documents/:document/progress", api.authWebAppMiddleware, api.getDocumentProgress)
// Behind Configuration Flag
if api.Config.SearchEnabled {

View File

@ -72,6 +72,18 @@ func (api *API) webManifest(c *gin.Context) {
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) {
// Merge Optional Template Data
var templateVarsBase = gin.H{}
@ -245,7 +257,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
// Handle Identified Document
if document.Coverfile != nil {
if *document.Coverfile == "UNKNOWN" {
c.File("./assets/no-cover.jpg")
c.File("./assets/images/no-cover.jpg")
return
}
@ -256,7 +268,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
_, err = os.Stat(safePath)
if err != nil {
log.Error("[getDocumentCover] File Should But Doesn't Exist:", err)
c.File("./assets/no-cover.jpg")
c.File("./assets/images/no-cover.jpg")
return
}
@ -309,7 +321,7 @@ func (api *API) getDocumentCover(c *gin.Context) {
// Return Unknown Cover
if coverFile == "UNKNOWN" {
c.File("./assets/no-cover.jpg")
c.File("./assets/images/no-cover.jpg")
return
}
@ -317,12 +329,12 @@ func (api *API) getDocumentCover(c *gin.Context) {
c.File(coverFilePath)
}
func (api *API) documentReader(c *gin.Context) {
func (api *API) getDocumentProgress(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
var rDoc requestDocumentID
if err := c.ShouldBindUri(&rDoc); err != nil {
log.Error("[documentReader] Invalid URI Bind")
log.Error("[getDocumentProgress] Invalid URI Bind")
errorPage(c, http.StatusNotFound, "Invalid document.")
return
}
@ -333,7 +345,7 @@ func (api *API) documentReader(c *gin.Context) {
})
if err != nil && err != sql.ErrNoRows {
log.Error("[documentReader] UpsertDocument DB Error:", err)
log.Error("[getDocumentProgress] UpsertDocument DB Error:", err)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("UpsertDocument DB Error: %v", err))
return
}
@ -343,15 +355,18 @@ func (api *API) documentReader(c *gin.Context) {
DocumentID: rDoc.DocumentID,
})
if err != nil {
log.Error("[documentReader] GetDocumentWithStats DB Error:", err)
log.Error("[getDocumentProgress] GetDocumentWithStats DB Error:", err)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDocumentWithStats DB Error: %v", err))
return
}
c.HTML(http.StatusOK, "reader", gin.H{
"SearchEnabled": api.Config.SearchEnabled,
"Progress": progress.Progress,
"Data": document,
c.JSON(http.StatusOK, gin.H{
"id": document.ID,
"title": document.Title,
"author": document.Author,
"words": document.Words,
"progress": progress.Progress,
"percentage": document.Percentage,
})
}

View File

@ -636,7 +636,7 @@ func (api *API) downloadDocument(c *gin.Context) {
}
// 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)
}

View File

Before

Width:  |  Height:  |  Size: 511 KiB

After

Width:  |  Height:  |  Size: 511 KiB

View File

Before

Width:  |  Height:  |  Size: 699 KiB

After

Width:  |  Height:  |  Size: 699 KiB

View File

Before

Width:  |  Height:  |  Size: 462 KiB

After

Width:  |  Height:  |  Size: 462 KiB

View File

Before

Width:  |  Height:  |  Size: 457 KiB

After

Width:  |  Height:  |  Size: 457 KiB

View File

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 213 KiB

50
assets/js/idb-keyval.js Normal file
View File

@ -0,0 +1,50 @@
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 } = window.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;
}, {});
});
},
};
})();

155
assets/offline/index.html Normal file
View File

@ -0,0 +1,155 @@
<!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/js/idb-keyval.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 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>

195
assets/offline/index.js Normal file
View File

@ -0,0 +1,195 @@
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() {
if (document.location.pathname !== "/offline")
window.history.replaceState(null, null, "/offline");
await SW.install().catch((e) => {
console.log("Service Worker Install Error:", e);
});
console.log("Registered");
getCachedDocuments().then(populateDOM);
}
/**
* Ask service worker for list of cached documents.
**/
async function getCachedDocuments() {
let cachedDocuments = await IDB.find(/^DOCUMENT-/, true);
let cachedSWData = await SW.send({ type: GET_SW_CACHE });
return cachedSWData;
}
async function sendSWMessage(data) {
let swReg = await navigator.serviceWorker.ready;
if (!swReg.active) return;
function randomID() {
return "00000000000000000000000000000000".replace(/[018]/g, (c) =>
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))))
.toString(16)
.toUpperCase()
);
}
let id = randomID();
navigator.serviceWorker.addEventListener(id, (event) => {
console.log("Response:", event);
navigator.serviceWorker.removeEventListener(id);
});
swReg.active.postMessage({ id, data });
}
/**
* Populate DOM with cached documents.
**/
function populateDOM(data) {
let allDocuments = document.querySelector("#container div");
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() {}
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 Message:", id, data);
if (!outstandingMessages[id])
return console.warn("Invalid Outstanding Message:", { id, data });
outstandingMessages[id](data);
delete outstandingMessages[id];
});
return {
async install() {
// Register Service Worker
swInstance = await navigator.serviceWorker.register("/sw.js");
console.log(swInstance);
swInstance.onupdatefound = (data) => console.log("Update Found:", data);
// Wait for Registration / Update
let serviceWorker =
swInstance.installing || swInstance.waiting || swInstance.active;
if (serviceWorker.state == "activated") return;
await new Promise((resolve) => {
serviceWorker.onstatechange = (data) => {
console.log("State Change:", serviceWorker.state);
if (serviceWorker.state == "activated") resolve();
};
});
},
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;
},
};
})();
// Initialize
window.addEventListener("DOMContentLoaded", initOffline);

View File

@ -14,11 +14,21 @@
/>
<meta name="theme-color" content="#D2B48C" />
<title>Book Manager - {{block "title" .}}{{end}}</title>
<title>Book Manager - Reader</title>
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/assets/style.css" />
<!-- Libraries -->
<script src="/assets/js/platform.js"></script>
<script src="/assets/js/jszip.min.js"></script>
<script src="/assets/js/epub.min.js"></script>
<script src="/assets/js/no-sleep.js"></script>
<script src="/assets/js/idb-keyval.js"></script>
<!-- Reader -->
<script src="/assets/reader/index.js"></script>
<style>
html,
body {
@ -66,7 +76,7 @@
>
<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">
<a href="../{{ .Data.ID }}">
<a href="#">
<svg
width="32"
height="32"
@ -101,8 +111,11 @@
<div class="flex gap-10 h-full p-4 pl-14 rounded">
<div class="h-full my-auto relative">
<a href="../{{ .Data.ID }}">
<img class="rounded object-cover h-full" src="./cover" />
<a href="#">
<img
class="rounded object-cover h-full"
src="/assets/images/no-cover.jpg"
/>
</a>
</div>
<div class="flex gap-7 justify-around dark:text-white text-sm">
@ -113,7 +126,7 @@
<p
class="font-medium whitespace-nowrap text-ellipsis overflow-hidden max-w-[50dvw]"
>
{{ or .Data.Title "N/A" }}
"N/A"
</p>
</div>
</div>
@ -123,7 +136,7 @@
<p
class="font-medium whitespace-nowrap text-ellipsis overflow-hidden max-w-[50dvw]"
>
{{ or .Data.Author "N/A" }}
"N/A"
</p>
</div>
</div>
@ -240,7 +253,7 @@
</div>
</div>
</div>
{{block "content" .}}{{end}}
<div id="viewer" class="w-full h-full"></div>
</main>
</body>
</html>

View File

@ -1,6 +1,51 @@
const THEMES = ["light", "tan", "blue", "gray", "black"];
const THEME_FILE = "/assets/reader/readerThemes.css";
async function initReader() {
let documentData;
let filePath;
const urlParams = new URLSearchParams(window.location.search);
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();
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 {
bookState = {
currentWord: 0,
@ -13,6 +58,9 @@ class EBookReader {
};
constructor(file, bookState) {
// Save IDB Cache
IDB.set("DOCUMENT-" + bookState.id, bookState);
// Set Variables
Object.assign(this.bookState, bookState);
@ -61,7 +109,7 @@ class EBookReader {
// Get Stats
let stats = this.getBookStats();
this.updateBookStats(stats);
this.updateBookStatElements(stats);
}.bind(this);
// Register Content Hook
@ -381,25 +429,41 @@ class EBookReader {
// ------------------------------------------------ //
// --------------- Bottom & Top Bar --------------- //
// ------------------------------------------------ //
let emSize = parseFloat(getComputedStyle(renderDoc.body).fontSize);
renderDoc.addEventListener("click", function (event) {
let barPixels = emSize * 5;
renderDoc.addEventListener(
"click",
function (event) {
// Get Window Dimensions
let windowWidth = window.innerWidth;
let windowHeight = window.innerHeight;
// Calculate X & Y Hot Zones
let barPixels = windowHeight * 0.2;
let pagePixels = windowWidth * 0.2;
// Calculate Top & Bottom Thresholds
let top = barPixels;
let bottom = window.innerHeight - top;
let left = barPixels / 2;
let right = window.innerWidth - left;
// Calculate Left & Right Thresholds
let left = pagePixels;
let right = windowWidth - left;
if (event.clientY < top) handleSwipeDown();
else if (event.clientY > bottom) handleSwipeUp();
else if (event.screenX < left) prevPage();
else if (event.screenX > right) nextPage();
// Calculate Relative Coords
let leftOffset = this.views().container.scrollLeft;
let yCoord = event.clientY;
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(
"wheel",
@ -565,26 +629,8 @@ class EBookReader {
* Progresses to the next page & monitors reading activity
**/
async nextPage() {
// Flush Activity
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,
});
// Create Activity
await this.createActivity();
// Render Next Page
await this.rendition.next();
@ -594,24 +640,16 @@ class EBookReader {
// Update Stats
let stats = this.getBookStats();
this.updateBookStats(stats);
this.updateBookStatElements(stats);
// Update & Flush Progress
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();
// Create Progress
this.createProgress();
}
/**
* Progresses to the previous page & monitors reading activity
**/
async prevPage() {
// Flush Activity
this.flushActivity();
// Render Previous Page
await this.rendition.prev();
@ -624,14 +662,10 @@ class EBookReader {
// Update Stats
let stats = this.getBookStats();
this.updateBookStats(stats);
this.updateBookStatElements(stats);
// Update & Flush Progress
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();
// Create Progress
this.createProgress();
}
/**
@ -652,88 +686,95 @@ class EBookReader {
this.highlightPositionMarker();
}
/**
* Normalize and flush activity
**/
async flushActivity() {
// Process & Reset Activity
let allActivity = this.bookState.readActivity;
this.bookState.readActivity = [];
async createActivity() {
// WPM MAX & MIN
const WPM_MAX = 2000;
const WPM_MIN = 100;
let normalizedActivity = allActivity
// Exclude Fast WPM
.filter((item) => item.pageWords / (item.elapsedTime / 60000) < WPM_MAX)
.map((item) => {
let pageWPM = item.pageWords / (item.elapsedTime / 60000);
// Get Elapsed Time
let pageStart = this.bookState.pageStart;
let elapsedTime = Date.now() - pageStart;
// Min WPM
if (pageWPM < WPM_MIN) {
// TODO - Exclude Event?
item.elapsedTime = (item.pageWords / WPM_MIN) * 60000;
}
// Update Current Word
let pageWords = await this.getVisibleWordCount();
let startingWord = this.bookState.currentWord;
let percentRead = pageWords / this.bookState.words;
this.bookState.currentWord += pageWords;
item.pages = Math.round(1 / item.percentRead);
let pageWPM = pageWords / (elapsedTime / 60000);
item.page = Math.round(
(item.startingWord * item.pages) / this.bookState.words
// Exclude Ridiculous WPM
// if (pageWPM >= WPM_MAX) return;
// Ensure WPM Minimum
if (pageWPM < WPM_MIN) elapsedTime = (pageWords / WPM_MIN) * 60000;
let totalPages = Math.round(1 / percentRead);
let currentPage = Math.round(
(startingWord * totalPages) / this.bookState.words
);
// Estimate Accuracy Loss (Debugging)
// let wordLoss = Math.abs(
// item.pageWords - this.bookState.words / item.pages
// );
// console.log("Word Loss:", wordLoss);
return {
document: this.bookState.id,
duration: Math.round(item.elapsedTime / 1000),
start_time: Math.round(item.startTime / 1000),
page: item.page,
pages: item.pages,
};
});
if (normalizedActivity.length == 0) return;
console.log("Flushing Activity...");
// Create Activity Event
let activityEvent = {
device_id: this.readerSettings.deviceID,
device: this.readerSettings.deviceName,
activity: normalizedActivity,
activity: [
{
document: this.bookState.id,
duration: Math.round(elapsedTime / 1000),
start_time: Math.round(pageStart / 1000),
page: currentPage,
pages: totalPages,
},
],
};
// Flush -> Offline Cache IDB
this.flushActivity(activityEvent).catch(async (e) => {
console.error("Activity Flush Failed:", {
error: e,
data: activityEvent,
});
// Get & Update Activity
let existingActivity = await IDB.get("ACTIVITY", { activity: [] });
existingActivity.device_id = activityEvent.device_id;
existingActivity.device = activityEvent.device;
existingActivity.activity.push(...activityEvent.activity);
// Update IDB
await IDB.set("ACTIVITY", existingActivity);
});
}
/**
* Normalize and flush activity
**/
flushActivity(activityEvent) {
console.log("Flushing Activity...");
// Flush Activity
fetch("/api/ko/activity", {
return fetch("/api/ko/activity", {
method: "POST",
body: JSON.stringify(activityEvent),
})
.then(async (r) =>
}).then(async (r) =>
console.log("Flushed Activity:", {
response: r,
json: await r.json(),
data: activityEvent,
})
)
.catch((e) =>
console.error("Activity Flush Failed:", {
error: e,
data: activityEvent,
})
);
}
/**
* Flush progress to the API. Called when the page changes.
**/
async flushProgress() {
console.log("Flushing Progress...");
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 Progress Event
// Create Event
let progressEvent = {
document: this.bookState.id,
device_id: this.readerSettings.deviceID,
@ -745,23 +786,38 @@ class EBookReader {
progress: this.bookState.progress,
};
// Flush -> Offline Cache IDB
this.flushProgress(progressEvent).catch(async (e) => {
console.error("Progress Flush Failed:", {
error: e,
data: progressEvent,
});
// Get & Update Activity
let existingProgress = await IDB.get("PROGRESS", []);
existingProgress.push(progressEvent);
// Update IDB
await IDB.set("PROGRESS", existingProgress);
});
}
/**
* Flush progress to the API. Called when the page changes.
**/
flushProgress(progressEvent) {
console.log("Flushing Progress...");
// Flush Progress
fetch("/api/ko/syncs/progress", {
return fetch("/api/ko/syncs/progress", {
method: "PUT",
body: JSON.stringify(progressEvent),
})
.then(async (r) =>
}).then(async (r) =>
console.log("Flushed Progress:", {
response: r,
json: await r.json(),
data: progressEvent,
})
)
.catch((e) =>
console.error("Progress Flush Failed:", {
error: e,
data: progressEvent,
})
);
}
@ -812,7 +868,7 @@ class EBookReader {
/**
* Update elements with stats
**/
updateBookStats(data) {
updateBookStatElements(data) {
if (!data) return;
let chapterStatus = document.querySelector("#chapter-status");
@ -1076,3 +1132,5 @@ class EBookReader {
);
}
}
document.addEventListener("DOMContentLoaded", initReader);

View File

@ -2046,6 +2046,11 @@ video {
}
@media (min-width: 1024px) {
.lg\:mx-48 {
margin-left: 12rem;
margin-right: 12rem;
}
.lg\:ml-44 {
margin-left: 11rem;
}

161
assets/sw.js Normal file
View File

@ -0,0 +1,161 @@
// Local Consts
const SW_VERSION = 1;
const SW_CACHE_NAME = "OFFLINE_V1";
// Message Consts
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";
// Assets
const ASSETS_DOCUMENT = /^\/documents\/[a-zA-Z0-9]{32}\/(cover|file|progress)$/;
const ASSETS_OFFLINE = [
// Offline Resources
"/offline",
"/assets/offline/index.js",
"/assets/reader/index.js",
"/assets/images/no-cover.jpg",
// App Style
"/manifest.json",
"/assets/style.css",
// Reader & Offline Libraries
"/assets/js/platform.js",
"/assets/js/jszip.min.js",
"/assets/js/epub.min.js",
"/assets/js/no-sleep.js",
"/assets/js/idb-keyval.js",
];
function wantCache(request) {
let urlPath = new URL(request.url).pathname;
if (ASSETS_OFFLINE.includes(urlPath)) return true;
if (urlPath.match(ASSETS_DOCUMENT)) return true;
return false;
}
/**
* Nuke Cache
**/
function purgeCache() {
return caches.keys().then(function (names) {
for (let name of names) caches.delete(name);
});
}
/**
* Update Cache
**/
async function updateCache(request) {
let cache = await caches.open(SW_CACHE_NAME);
console.log("UPDATING CACHE:", request.url);
return fetch(request).then((response) => {
const resClone = response.clone();
if (response.status < 400) cache.put(request, resClone);
return response;
});
}
/**
* Pre-Cache Resources on Install
**/
function cacheOfflineResources() {
return caches.open(SW_CACHE_NAME).then(function (cache) {
return cache.addAll(ASSETS_OFFLINE);
});
}
/**
* Install & Update Listener -> Cache Offline Resources
**/
self.addEventListener("install", function (event) {
console.log("INSTALL:", event);
event.waitUntil(cacheOfflineResources());
});
/**
* Message Listener -> Communication Channel Page <-> SW
**/
self.addEventListener("message", (event) => {
console.log("MESSAGE:", event);
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]))
);
let cachedDocuments = await Promise.all(
documentIDs
.filter(
(id) =>
docResources.includes("/documents/" + id + "/file") &&
docResources.includes("/documents/" + id + "/progress")
)
.map(async (id) => {
let resp = await cache.match("/documents/" + id + "/progress");
return resp.json();
})
);
event.source.postMessage({ id, data: cachedDocuments });
});
// TODO
} else if (data.type === DEL_SW_CACHE) {
// TODO
} else {
event.source.postMessage({ id, data: { pong: 1 } });
}
});
/**
* Fetch Listener -> Cache
* - Covers
* - Files
* - Assets (Styles, JS Libraries)
*
* NOTE: We do not cache regular app resources. We will fallback to the
* offline reader.
**/
self.addEventListener("fetch", (event) => {
event.respondWith(
(async function () {
// Bypass Lazy Caching
if (event.request.url.endsWith("/progress")) {
return updateCache(event.request).catch((e) =>
caches.match(event.request)
);
}
// Get Potential Cache
let cachedResponse = await caches.match(event.request);
// Update Cache Asynchronously (If Wanted)
let newResponse = (
wantCache(event.request)
? updateCache(event.request)
: fetch(event.request)
).catch((e) => caches.match("/offline"));
return cachedResponse || newResponse;
})()
);
});

View File

@ -649,7 +649,7 @@ end
function SyncNinja:checkDocuments(interactive)
logger.dbg("SyncNinja: checkDocuments")
-- ensure document sync enabled
-- Ensure Document Sync Enabled
if self.settings.sync_documents ~= true then return end
-- API Request Data
@ -723,6 +723,8 @@ function SyncNinja:downloadDocuments(doc_metadata, interactive)
logger.dbg("SyncNinja: downloadDocuments")
-- TODO
-- - OPDS Sufficient?
-- - Auto Configure OPDS?
end
function SyncNinja:uploadDocumentMetadata(doc_metadata, interactive)

View File

@ -1,6 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./templates/**/*.html", "./assets/reader/index.js"],
content: [
"./templates/**/*.html",
"./assets/offline/*.{html,js}",
"./assets/reader/*.{html,js}",
],
theme: {
extend: {},
},

View File

@ -17,7 +17,7 @@
{{ if .Data.Filepath }}
<a
href="./{{ .Data.ID }}/reader"
href="/reader?id={{ .Data.ID }}"
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>
{{ end }}

View File

@ -134,19 +134,19 @@
>
<img
class="w-full h-screen object-cover ease-in-out top-0 left-0"
src="/assets/book1.jpg"
src="/assets/images/book1.jpg"
/>
<img
class="w-full h-screen object-cover ease-in-out top-0 left-0"
src="/assets/book2.jpg"
src="/assets/images/book2.jpg"
/>
<img
class="w-full h-screen object-cover ease-in-out top-0 left-0"
src="/assets/book3.jpg"
src="/assets/images/book3.jpg"
/>
<img
class="w-full h-screen object-cover ease-in-out top-0 left-0"
src="/assets/book4.jpg"
src="/assets/images/book4.jpg"
/>
</div>
</div>

View File

@ -1,22 +0,0 @@
{{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}}