[fix] service worker route regex bug, [add] device selector / creator
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Evan Reichard 2023-11-26 21:41:17 -05:00
parent e823a794cf
commit bb837dd30e
12 changed files with 255 additions and 40 deletions

View File

@ -97,7 +97,11 @@ func (api *API) registerWebAppRoutes() {
// Local / Offline Static Pages (No Template, No Auth)
api.Router.GET("/local", api.localDocuments)
// Reader (Reader Page, Document Progress, Devices)
api.Router.GET("/reader", api.documentReader)
api.Router.GET("/reader/devices", api.authWebAppMiddleware, api.getDevices)
api.Router.GET("/reader/progress/:document", api.authWebAppMiddleware, api.getDocumentProgress)
// Web App
api.Router.GET("/", api.authWebAppMiddleware, api.createAppResourcesRoute("home"))
@ -106,7 +110,6 @@ func (api *API) registerWebAppRoutes() {
api.Router.GET("/documents/:document", api.authWebAppMiddleware, api.createAppResourcesRoute("document"))
api.Router.GET("/documents/:document/cover", api.authWebAppMiddleware, api.getDocumentCover)
api.Router.GET("/documents/:document/file", api.authWebAppMiddleware, api.downloadDocument)
api.Router.GET("/documents/:document/progress", api.authWebAppMiddleware, api.getDocumentProgress)
api.Router.GET("/login", api.createAppResourcesRoute("login"))
api.Router.GET("/logout", api.authWebAppMiddleware, api.authLogout)
api.Router.GET("/register", api.createAppResourcesRoute("login", gin.H{"Register": true}))

View File

@ -113,10 +113,9 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
}
templateVars["User"] = userID
// Potential URL Parameters
qParams := bindQueryParams(c)
if routeName == "documents" {
qParams := bindQueryParams(c, 9)
var query *string
if qParams.Search != nil && *qParams.Search != "" {
search := "%" + *qParams.Search + "%"
@ -181,6 +180,8 @@ func (api *API) createAppResourcesRoute(routeName string, args ...map[string]any
templateVars["Data"] = document
templateVars["TotalTimeLeftSeconds"] = int64((100.0 - document.Percentage) * float64(document.SecondsPerPercent))
} else if routeName == "activity" {
qParams := bindQueryParams(c, 15)
activityFilter := database.GetActivityParams{
UserID: userID,
Offset: (*qParams.Page - 1) * *qParams.Limit,
@ -397,6 +398,20 @@ func (api *API) getDocumentProgress(c *gin.Context) {
})
}
func (api *API) getDevices(c *gin.Context) {
rUser, _ := c.Get("AuthorizedUser")
devices, err := api.DB.Queries.GetDevices(api.DB.Ctx, rUser.(string))
if err != nil && err != sql.ErrNoRows {
log.Error("[getDevices] GetDevices DB Error:", err)
errorPage(c, http.StatusInternalServerError, fmt.Sprintf("GetDevices DB Error: %v", err))
return
}
c.JSON(http.StatusOK, devices)
}
func (api *API) uploadNewDocument(c *gin.Context) {
var rDocUpload requestDocumentUpload
if err := c.ShouldBind(&rDocUpload); err != nil {
@ -981,13 +996,12 @@ func (api *API) getDocumentsWordCount(documents []database.GetDocumentsWithStats
return nil
}
func bindQueryParams(c *gin.Context) queryParams {
func bindQueryParams(c *gin.Context, defaultLimit int64) queryParams {
var qParams queryParams
c.BindQuery(&qParams)
if qParams.Limit == nil {
var defaultValue int64 = 9
qParams.Limit = &defaultValue
qParams.Limit = &defaultLimit
} else if *qParams.Limit < 0 {
var zeroValue int64 = 0
qParams.Limit = &zeroValue

View File

@ -49,7 +49,7 @@ func (api *API) opdsEntry(c *gin.Context) {
},
Links: []opds.Link{
{
Href: "/api/opds/documents?limit=100",
Href: "/api/opds/documents",
TypeLink: "application/atom+xml;type=feed;profile=opds-catalog",
},
},
@ -66,8 +66,8 @@ func (api *API) opdsDocuments(c *gin.Context) {
userID = rUser.(string)
}
// Potential URL Parameters
qParams := bindQueryParams(c)
// Potential URL Parameters (Default Pagination - 100)
qParams := bindQueryParams(c, 100)
// Possible Query
var query *string
@ -160,7 +160,7 @@ func (api *API) opdsSearchDescription(c *gin.Context) {
rawXML := `<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
<ShortName>Search AnthoLume</ShortName>
<Description>Search AnthoLume</Description>
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition" template="/api/opds/documents?limit=100&search={searchTerms}"/>
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition" template="/api/opds/documents?search={searchTerms}"/>
</OpenSearchDescription>`
c.Data(http.StatusOK, "application/xml", []byte(rawXML))
}

File diff suppressed because one or more lines are too long

View File

@ -31,7 +31,6 @@
<script src="/assets/lib/jszip.min.js"></script>
<script src="/assets/lib/epub.min.js"></script>
<script src="/assets/lib/idb-keyval.min.js"></script>
<script src="/assets/lib/sw-helper.min.js"></script>
<!-- Local -->
<script src="/assets/common.js"></script>

View File

@ -20,7 +20,6 @@
<link rel="stylesheet" href="/assets/style.css" />
<!-- Libraries -->
<script src="/assets/lib/platform.min.js"></script>
<script src="/assets/lib/jszip.min.js"></script>
<script src="/assets/lib/epub.min.js"></script>
<script src="/assets/lib/no-sleep.min.js"></script>
@ -71,6 +70,10 @@
#top-bar:not(.top-0) {
top: calc((8em + env(safe-area-inset-top)) * -1);
}
select:invalid {
color: gray;
}
</style>
</head>
<body class="bg-gray-100 dark:bg-gray-800">
@ -260,5 +263,120 @@
</div>
<div id="viewer" class="w-full h-full"></div>
</main>
<!-- Device Selector -->
<div
id="device-selector"
class="hidden absolute top-0 left-0 w-full h-full z-50"
>
<div
class="fixed top-0 left-0 bg-black opacity-50 w-screen h-screen"
></div>
<div
class="relative flex flex-col gap-4 p-4 max-h-[95%] w-5/6 md:w-1/2 bg-white dark:bg-gray-800 overflow-scroll -translate-x-2/4 -translate-y-2/4 top-1/2 left-1/2 overflow-hidden shadow rounded"
>
<div class="text-center flex flex-col gap-2">
<h3 class="text-lg font-bold leading-6 dark:text-gray-300">
Select Device
</h3>
<p class="text-xs text-gray-500 text-center">
This device appears to be new! Please either assume an existing
device, or create a new one.
</p>
</div>
<div
class="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<div class="flex gap-4 flex-col">
<div class="flex relative min-w-[12em]">
<span
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.65517 2.22732C5.2225 2.34037 4.9438 2.50021 4.72718 2.71244C4.42179 3.01165 4.22268 3.43172 4.11382 4.225C4.00176 5.04159 4 6.12387 4 7.67568V16.2442C4.38867 15.9781 4.82674 15.7756 5.29899 15.6517C5.41296 15.6217 5.53103 15.5983 5.65517 15.5799V2.22732Z"
/>
<path
d="M7.31034 15.5135C7.32206 15.5135 7.33382 15.5135 7.34563 15.5135L20 15.5135V7.67568C20 6.12387 19.9982 5.04159 19.8862 4.22499C19.7773 3.43172 19.5782 3.01165 19.2728 2.71244C18.9674 2.41324 18.5387 2.21816 17.729 2.11151C16.8955 2.00172 15.7908 2 14.2069 2H9.7931C8.79138 2 7.98133 2.00069 7.31034 2.02897V15.5135Z"
/>
<path
d="M7.47341 17.1351C6.39395 17.1351 6.01657 17.1421 5.72738 17.218C4.93365 17.4264 4.30088 18.0044 4.02952 18.7558C4.0463 19.1382 4.07259 19.4746 4.11382 19.775C4.22268 20.5683 4.42179 20.9884 4.72718 21.2876C5.03258 21.5868 5.46135 21.7818 6.27103 21.8885C7.10452 21.9983 8.2092 22 9.7931 22H14.2069C15.7908 22 16.8955 21.9983 17.729 21.8885C18.5387 21.7818 18.9674 21.5868 19.2728 21.2876C19.5782 20.9884 19.7773 20.5683 19.8862 19.775C19.9776 19.1088 19.9956 18.2657 19.9991 17.1351H7.47341Z"
/>
</svg>
</span>
<select
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
id="source"
name="source"
required
>
<option value="" disabled selected hidden>
Select Existing Device
</option>
</select>
</div>
<button
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"
>
<span class="w-full">Assume Device</span>
</button>
</div>
</div>
<div
class="flex flex-col gap-2 grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"
>
<div class="flex gap-4 flex-col">
<div class="flex flex-col w-full grow">
<div class="flex relative">
<span
class="inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5.65517 2.22732C5.2225 2.34037 4.9438 2.50021 4.72718 2.71244C4.42179 3.01165 4.22268 3.43172 4.11382 4.225C4.00176 5.04159 4 6.12387 4 7.67568V16.2442C4.38867 15.9781 4.82674 15.7756 5.29899 15.6517C5.41296 15.6217 5.53103 15.5983 5.65517 15.5799V2.22732Z"
/>
<path
d="M7.31034 15.5135C7.32206 15.5135 7.33382 15.5135 7.34563 15.5135L20 15.5135V7.67568C20 6.12387 19.9982 5.04159 19.8862 4.22499C19.7773 3.43172 19.5782 3.01165 19.2728 2.71244C18.9674 2.41324 18.5387 2.21816 17.729 2.11151C16.8955 2.00172 15.7908 2 14.2069 2H9.7931C8.79138 2 7.98133 2.00069 7.31034 2.02897V15.5135Z"
/>
<path
d="M7.47341 17.1351C6.39395 17.1351 6.01657 17.1421 5.72738 17.218C4.93365 17.4264 4.30088 18.0044 4.02952 18.7558C4.0463 19.1382 4.07259 19.4746 4.11382 19.775C4.22268 20.5683 4.42179 20.9884 4.72718 21.2876C5.03258 21.5868 5.46135 21.7818 6.27103 21.8885C7.10452 21.9983 8.2092 22 9.7931 22H14.2069C15.7908 22 16.8955 21.9983 17.729 21.8885C18.5387 21.7818 18.9674 21.5868 19.2728 21.2876C19.5782 20.9884 19.7773 20.5683 19.8862 19.775C19.9776 19.1088 19.9956 18.2657 19.9991 17.1351H7.47341Z"
/>
</svg>
</span>
<input
type="text"
id="name"
name="name"
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
placeholder="New Device Name"
/>
</div>
</div>
<button
class="px-10 py-2 text-base font-semibold text-center text-white transition duration-200 ease-in bg-black shadow-md hover:text-black hover:bg-white focus:outline-none focus:ring-2"
>
<span class="w-full">Create Device</span>
</button>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@ -17,7 +17,7 @@ async function initReader() {
if (documentType == "REMOTE") {
// Get Server / Cached Document
let progressResp = await fetch("/documents/" + documentID + "/progress");
let progressResp = await fetch("/reader/progress/" + documentID);
documentData = await progressResp.json();
// Update With Local Cache
@ -145,14 +145,60 @@ class EBookReader {
);
}
this.readerSettings.deviceName =
this.readerSettings.deviceName ||
platform.os.toString() + " - " + platform.name;
// Device Already Set
if (this.readerSettings.deviceID) return;
this.readerSettings.deviceID = this.readerSettings.deviceID || randomID();
// Get Elements
let devicePopup = document.querySelector("#device-selector");
let devSelector = devicePopup.querySelector("select");
let devInput = devicePopup.querySelector("input");
let [assumeButton, createButton] = devicePopup.querySelectorAll("button");
// Save Settings (Device ID)
// Set Visible
devicePopup.classList.remove("hidden");
// Add Devices
fetch("/reader/devices").then(async (r) => {
let data = await r.json();
data.forEach((item) => {
let optionEl = document.createElement("option");
optionEl.value = item.id;
optionEl.textContent = item.device_name;
devSelector.appendChild(optionEl);
});
});
assumeButton.addEventListener("click", () => {
let deviceID = devSelector.value;
if (deviceID == "") {
// TODO - Error Message
return;
}
let selectedOption = devSelector.children[devSelector.selectedIndex];
let deviceName = selectedOption.textContent;
this.readerSettings.deviceID = deviceID;
this.readerSettings.deviceName = deviceName;
this.saveSettings();
devicePopup.classList.add("hidden");
});
createButton.addEventListener("click", () => {
let deviceName = devInput.value.trim();
if (deviceName == "") {
// TODO - Error Message
return;
}
this.readerSettings.deviceID = randomID();
this.readerSettings.deviceName = deviceName;
this.saveSettings();
devicePopup.classList.add("hidden");
});
}
/**

View File

@ -44,7 +44,7 @@ const ROUTES = [
type: CACHE_UPDATE_ASYNC,
},
{
route: /^\/documents\/[a-zA-Z0-9]{32}\/progress$/,
route: /^\/reader\/progress\/[a-zA-Z0-9]{32}$/,
type: CACHE_UPDATE_SYNC,
},
{
@ -74,7 +74,6 @@ const PRECACHE_ASSETS = [
"/assets/common.js",
// Library Assets
"/assets/lib/platform.min.js",
"/assets/lib/jszip.min.js",
"/assets/lib/epub.min.js",
"/assets/lib/no-sleep.min.js",
@ -120,7 +119,9 @@ async function handleFetch(event) {
// Find Directive
const directive = ROUTES.find(
(item) => url.match(item.route) || url == item.route
(item) =>
(item.route instanceof RegExp && url.match(item.route)) ||
url == item.route
) || { type: CACHE_NEVER };
// Get Fallback
@ -170,12 +171,22 @@ function handleMessage(event) {
caches.open(SW_CACHE_NAME).then(async (cache) => {
let allKeys = await cache.keys();
// Get Cached Resources
let docResources = allKeys
.map((item) => new URL(item.url).pathname)
.filter((item) => item.startsWith("/documents/"));
.filter(
(item) =>
item.startsWith("/documents/") ||
item.startsWith("/reader/progress/")
);
// Derive Unique IDs
let documentIDs = Array.from(
new Set(docResources.map((item) => item.split("/")[2]))
new Set(
docResources
.filter((item) => item.startsWith("/documents/"))
.map((item) => item.split("/")[2])
)
);
/**
@ -188,10 +199,10 @@ function handleMessage(event) {
.filter(
(id) =>
docResources.includes("/documents/" + id + "/file") &&
docResources.includes("/documents/" + id + "/progress")
docResources.includes("/reader/progress/" + id)
)
.map(async (id) => {
let url = "/documents/" + id + "/progress";
let url = "/reader/progress/" + id;
let currentCache = await caches.match(url);
let resp = await updateCache(url).catch((e) => currentCache);
return resp.json();
@ -201,13 +212,12 @@ function handleMessage(event) {
event.source.postMessage({ id, data: cachedDocuments });
});
} else if (data.type === DEL_SW_CACHE) {
let basePath = "/documents/" + data.id;
caches
.open(SW_CACHE_NAME)
.then((cache) =>
Promise.all([
cache.delete(basePath + "/file"),
cache.delete(basePath + "/progress"),
cache.delete("/documents/" + data.id + "/file"),
cache.delete("/reader/progress/" + data.id),
])
)
.then(() => event.source.postMessage({ id, data: "SUCCESS" }))

View File

@ -40,9 +40,12 @@ WHERE id = $id;
WITH filtered_activity AS (
SELECT
document_id,
device_id,
user_id,
start_time,
duration,
ROUND(CAST(start_percentage AS REAL) * 100, 2) AS start_percentage,
ROUND(CAST(end_percentage AS REAL) * 100, 2) AS end_percentage,
ROUND(CAST(end_percentage - start_percentage AS REAL) * 100, 2) AS read_percentage
FROM activity
WHERE
@ -60,10 +63,13 @@ WITH filtered_activity AS (
SELECT
document_id,
device_id,
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', activity.start_time, users.time_offset) AS TEXT) AS start_time,
title,
author,
duration,
start_percentage,
end_percentage,
read_percentage
FROM filtered_activity AS activity
LEFT JOIN documents ON documents.id = activity.document_id
@ -128,6 +134,7 @@ WHERE id = $device_id LIMIT 1;
-- name: GetDevices :many
SELECT
devices.id,
devices.device_name,
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.created_at, users.time_offset) AS TEXT) AS created_at,
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.last_synced, users.time_offset) AS TEXT) AS last_synced

View File

@ -150,9 +150,12 @@ const getActivity = `-- name: GetActivity :many
WITH filtered_activity AS (
SELECT
document_id,
device_id,
user_id,
start_time,
duration,
ROUND(CAST(start_percentage AS REAL) * 100, 2) AS start_percentage,
ROUND(CAST(end_percentage AS REAL) * 100, 2) AS end_percentage,
ROUND(CAST(end_percentage - start_percentage AS REAL) * 100, 2) AS read_percentage
FROM activity
WHERE
@ -170,10 +173,13 @@ WITH filtered_activity AS (
SELECT
document_id,
device_id,
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', activity.start_time, users.time_offset) AS TEXT) AS start_time,
title,
author,
duration,
start_percentage,
end_percentage,
read_percentage
FROM filtered_activity AS activity
LEFT JOIN documents ON documents.id = activity.document_id
@ -190,10 +196,13 @@ type GetActivityParams struct {
type GetActivityRow struct {
DocumentID string `json:"document_id"`
DeviceID string `json:"device_id"`
StartTime string `json:"start_time"`
Title *string `json:"title"`
Author *string `json:"author"`
Duration int64 `json:"duration"`
StartPercentage float64 `json:"start_percentage"`
EndPercentage float64 `json:"end_percentage"`
ReadPercentage float64 `json:"read_percentage"`
}
@ -214,10 +223,13 @@ func (q *Queries) GetActivity(ctx context.Context, arg GetActivityParams) ([]Get
var i GetActivityRow
if err := rows.Scan(
&i.DocumentID,
&i.DeviceID,
&i.StartTime,
&i.Title,
&i.Author,
&i.Duration,
&i.StartPercentage,
&i.EndPercentage,
&i.ReadPercentage,
); err != nil {
return nil, err
@ -390,6 +402,7 @@ func (q *Queries) GetDevice(ctx context.Context, deviceID string) (Device, error
const getDevices = `-- name: GetDevices :many
SELECT
devices.id,
devices.device_name,
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.created_at, users.time_offset) AS TEXT) AS created_at,
CAST(STRFTIME('%Y-%m-%d %H:%M:%S', devices.last_synced, users.time_offset) AS TEXT) AS last_synced
@ -400,6 +413,7 @@ ORDER BY devices.last_synced DESC
`
type GetDevicesRow struct {
ID string `json:"id"`
DeviceName string `json:"device_name"`
CreatedAt string `json:"created_at"`
LastSynced string `json:"last_synced"`
@ -414,7 +428,12 @@ func (q *Queries) GetDevices(ctx context.Context, userID string) ([]GetDevicesRo
var items []GetDevicesRow
for rows.Next() {
var i GetDevicesRow
if err := rows.Scan(&i.DeviceName, &i.CreatedAt, &i.LastSynced); err != nil {
if err := rows.Scan(
&i.ID,
&i.DeviceName,
&i.CreatedAt,
&i.LastSynced,
); err != nil {
return nil, err
}
items = append(items, i)

View File

@ -38,7 +38,7 @@
<p>{{ $activity.Duration }}</p>
</td>
<td class="p-3 border-b border-gray-200">
<p>{{ $activity.ReadPercentage }}%</p>
<p>{{ $activity.EndPercentage }}%</p>
</td>
</tr>
{{end}}

View File

@ -35,7 +35,7 @@
type="text"
id="search"
name="search"
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
class="flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-2 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"
placeholder="Search Author / Title"
/>
</div>