Add IDB-backed caching

This commit is contained in:
Hamcha 2020-06-21 19:16:01 +02:00
parent 7930f96171
commit df914edcb5
Signed by untrusted user: hamcha
GPG key ID: 41467804B19A3315
11 changed files with 470 additions and 285 deletions

View file

@ -19,6 +19,7 @@ module.exports = {
"no-param-reassign": "off",
"no-alert": "off",
"no-console": "off",
"func-names": "off",
"@typescript-eslint/ban-ts-comment": [
"error",
{

23
LICENSE Normal file
View file

@ -0,0 +1,23 @@
This code is license under ISC, full copy below:
Copyright 2020, Alessandro Gatti
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
This code uses idb-keyval, licensed under Apache 2:
Copyright 2016, Jake Archibald
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -8,4 +8,4 @@
## Development
`yarn && yarn dev`
`yarn dev`

View file

@ -18,6 +18,7 @@
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-import": "2.21.2",
"eslint-plugin-prettier": "^3.1.4",
"idb-keyval": "^3.2.0",
"parcel-bundler": "^1.12.4",
"parcel-plugin-sw-cache": "^0.3.1",
"prettier": "^2.0.5",

View file

@ -1,7 +1,9 @@
// @ts-expect-error: Asset imports are handled by parcel
import speen from "~/assets/images/speen.svg";
import { getPageHTML } from "./wiki";
import userscript from "./userscript";
import { processHTML, bindFunctions, CURRENT_VERSION } from "./userscript";
import cache from "./cache";
import { nextAnimationFrame } from "./utils";
// @ts-expect-error: Parcel image import
import unknown from "~/assets/images/tab-icons/unknown.svg";
@ -22,21 +24,52 @@ function initWaiting(elem: HTMLElement) {
}
async function loadPage(page: string, elem: HTMLElement) {
// Fetch page content
console.log(`${page}: fetching`);
let html = await getPageHTML(page);
let html: string | null = null;
const key = `page:${page}`;
// Convert relative links to absolute
html = html.replace(/"\/wiki/gi, '"//tgstation13.org/wiki');
// Check cache for pre-processed page
try {
const cachedPage = await cache.get<string>(key);
if (cachedPage) {
if (cachedPage.version === CURRENT_VERSION) {
console.log(`${page}: found cached entry`);
html = cachedPage.value;
} else {
console.log(`${page}: found outdated cache entry`);
}
}
} catch (e) {
console.log(`${page}: failed to retrieve cache entry:`, e);
}
// Fetch page content
if (!html) {
console.log(`${page}: fetching`);
html = await getPageHTML(page);
// Convert relative links to absolute (and proxied)
html = html.replace(/"\/wiki/gi, '"//tgproxy.ovo.ovh/wiki');
await nextAnimationFrame();
// Set as HTML content and run HTML manipulations on it
requestAnimationFrame(() => {
elem.innerHTML = html;
console.log(`${page}: processing`);
userscript(elem, page);
processHTML(elem, page);
// Save result to cache
cache.set(key, elem.innerHTML, CURRENT_VERSION).then(() => {
console.log(`${page}: saved to cache`);
});
} else {
// Set cached content as HTML
elem.innerHTML = html;
}
bindFunctions(elem, page);
console.log(`${page}: userscript applied`);
elem.classList.remove("waiting");
});
}
type TabElements = {

110
src/cache.ts Normal file
View file

@ -0,0 +1,110 @@
/* eslint-disable no-shadow */
interface CacheEntry<T> {
version: string;
value: T;
}
export class Store {
readonly dbp: Promise<IDBDatabase>;
constructor(dbName = "tg-cache", readonly storeName = "keyval") {
this.dbp = new Promise((resolve, reject) => {
const openreq = indexedDB.open(dbName, 1);
openreq.onerror = () => reject(openreq.error);
openreq.onsuccess = () => resolve(openreq.result);
// First time setup: create an empty object store
openreq.onupgradeneeded = () => {
openreq.result.createObjectStore(storeName);
};
});
}
withIDBStore(
type: IDBTransactionMode,
callback: (store: IDBObjectStore) => void
): Promise<void> {
return this.dbp.then(
(db) =>
new Promise<void>((resolve, reject) => {
const transaction = db.transaction(this.storeName, type);
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error);
transaction.onerror = () => reject(transaction.error);
callback(transaction.objectStore(this.storeName));
})
);
}
}
let defaultStore: Store;
function getDefaultStore() {
if (!defaultStore) defaultStore = new Store();
return defaultStore;
}
export function get<Type>(
key: IDBValidKey,
store = getDefaultStore()
): Promise<CacheEntry<Type>> {
let req: IDBRequest;
return store
.withIDBStore("readonly", (store) => {
req = store.get(key);
})
.then(() => req.result);
}
export function set<Type>(
key: IDBValidKey,
value: Type,
version: string,
store = getDefaultStore()
): Promise<void> {
return store.withIDBStore("readwrite", (store) => {
store.put({ version, value }, key);
});
}
export function del(
key: IDBValidKey,
store = getDefaultStore()
): Promise<void> {
return store.withIDBStore("readwrite", (store) => {
store.delete(key);
});
}
export function clear(store = getDefaultStore()): Promise<void> {
return store.withIDBStore("readwrite", (store) => {
store.clear();
});
}
export function keys(store = getDefaultStore()): Promise<IDBValidKey[]> {
const dbkeys: IDBValidKey[] = [];
return store
.withIDBStore("readonly", (store) => {
// This would be store.getAllKeys(), but it isn't supported by Edge or Safari.
// And openKeyCursor isn't supported by Safari.
(store.openKeyCursor || store.openCursor).call(
store
).onsuccess = function () {
if (!this.result) return;
dbkeys.push(this.result.key);
this.result.continue();
};
})
.then(() => dbkeys);
}
export default {
get,
set,
keys,
del,
clear,
};

View file

@ -2,7 +2,145 @@ import { darken, ColorFmt, lighten } from "./darkmode";
import searchBox from "./search";
import { findParent } from "./utils";
export default function userscript(root: HTMLElement, docname: string): void {
// This is used for cache busting when userscript changes significantly.
// Only change it when you made changes to the processHTML part!
export const CURRENT_VERSION = "7930f961710641f022ef0cd3ad3277ffd4eab7eb";
function chemistryFixups(root: HTMLElement) {
// Fix inconsistencies with <p> on random parts
// Ideally I'd like a <p> or something on every part, wrapping it completely, but for now let's just kill 'em
root
.querySelectorAll(
"table.wikitable > tbody > tr:not(:first-child) > td:nth-child(2), .tooltiptext"
)
.forEach((td) => {
const tmp = td.cloneNode() as HTMLElement;
// The cast to Array is necessary because, while childNodes's NodeList technically has a forEach method, it's a live list and operations mess with its lenght in the middle of the loop.
// Nodes can only have one parent so append removes them from the original NodeList and shifts the following one back into the wrong index.
Array.from(td.childNodes).forEach((el) => {
if (el instanceof HTMLParagraphElement) {
tmp.append(...el.childNodes);
} else {
tmp.append(el);
}
});
td.parentNode.replaceChild(tmp, td);
});
// Enrich "x part" with checkboxes and parts
Array.from(root.querySelectorAll("td"))
.filter((el) => el.textContent.indexOf(" part") >= 0)
.forEach((el) => {
el.innerHTML = el.innerHTML.replace(
/((\d+)\s+(?:parts?|units?))(.*?(?:<\/a>|\n|$))/gi,
(match, ...m) =>
`<label class="bgus_part ${
m[2].includes("</a>") ? "bgus_part_tooltip" : ""
}" data-amount="${
m[1]
}"><input type="checkbox" class='bgus_checkbox bgus_hidden'/> <span class="bgus_part_label" data-src="${
m[0]
}">${m[0]}</span></label>${m[2].replace(
/(<a .+?<\/a>)/gi,
'<span class="bgus_nobreak bgus_nested_element">$1<span class="bgus_twistie"></span></span>'
)}`
);
});
// Wrap every recipe with extra metadata
root.querySelectorAll<HTMLElement>(".bgus_part").forEach((el) => {
if ("parts" in el.parentElement.dataset) {
el.parentElement.dataset.parts = (
parseInt(el.parentElement.dataset.parts, 10) +
parseInt(el.dataset.amount, 10)
).toString();
} else {
el.parentElement.dataset.parts = el.dataset.amount;
}
});
// Remove "Removed medicines" section
const remTable = root.querySelector(
"#Non-craftable_Medicines + h4 + p + table"
);
remTable.parentElement.removeChild(remTable);
// Restructure recipes to work in a narrow window
root
.querySelectorAll<HTMLElement>("div[data-name] .wikitable.sortable tr")
.forEach((row) => {
const sectionEl = findParent(
row,
(sel) => "name" in sel.dataset && sel.dataset.name !== ""
);
const section = sectionEl.dataset.name;
if (row.querySelector("td") === null) {
// Remove unused rows if found
const headers = row.querySelectorAll("th");
headers.forEach((th, i) => {
if (i < 2) {
th.classList.add("table-head");
return;
}
th.parentElement.removeChild(th);
});
return;
}
const rows = Array.from(row.querySelectorAll("td")).slice(1);
let treatment = null;
let desc = null;
let metabolism = null;
let overdose = null;
let addiction = null;
// Handle special cases
switch (section) {
case "Components":
case "Virology Recipes":
[desc] = rows;
break;
case "Narcotics":
[desc, metabolism, overdose, addiction] = rows;
break;
case "Explosive Strength":
case "Other Reagents":
case "Mutation Toxins":
[desc, metabolism] = rows;
break;
default:
// All fields
[treatment, desc, metabolism, overdose, addiction] = rows;
}
const title = row.querySelector("th");
let content = `<div class="reagent-header">${title.innerHTML}</div>`;
if (treatment) {
content += `<p class="treatment">${treatment.innerHTML}</p>`;
}
if (metabolism) {
content += `<p class="metabolism">${metabolism.innerHTML}</p>`;
}
if (addiction && addiction.innerHTML.trim() !== "N/A") {
content += `<p class="addiction">${addiction.innerHTML}</p>`;
}
if (overdose && overdose.innerHTML.trim() !== "N/A") {
content += `<p class="overdose">${overdose.innerHTML}</p>`;
}
if (desc) {
content += `<p>${desc.innerHTML}</p>`;
}
title.classList.add("reagent-ext");
title.innerHTML = content;
if (desc) desc.parentElement.removeChild(desc);
if (treatment) treatment.parentElement.removeChild(treatment);
if (metabolism) metabolism.parentElement.removeChild(metabolism);
if (overdose) overdose.parentElement.removeChild(overdose);
if (addiction) addiction.parentElement.removeChild(addiction);
});
// Enable page-specific CSS rules
root.classList.add("bchem");
}
export function processHTML(root: HTMLElement, docname: string): void {
// Add header
const header = document.createElement("h1");
header.className = "pageheader";
@ -98,57 +236,15 @@ export default function userscript(root: HTMLElement, docname: string): void {
}
});
// Tell user that better chemistry is loading
const postbody = root;
const statusMessage = document.createElement("div");
statusMessage.innerHTML = `
<table style="background-color: black; margin-bottom:10px;" width="95%" align="center">
<tbody><tr><td align="center">
<b>Hang on...</b> Better guides is loading.
</td></tr></tbody>
</table>`;
postbody.insertBefore(statusMessage, postbody.firstChild);
function betterChemistry() {
// Fix inconsistencies with <p> on random parts
// Ideally I'd like a <p> or something on every part, wrapping it completely, but for now let's just kill 'em
document
.querySelectorAll(
"table.wikitable > tbody > tr:not(:first-child) > td:nth-child(2), .tooltiptext"
)
.forEach((td) => {
const tmp = td.cloneNode() as HTMLElement;
// The cast to Array is necessary because, while childNodes's NodeList technically has a forEach method, it's a live list and operations mess with its lenght in the middle of the loop.
// Nodes can only have one parent so append removes them from the original NodeList and shifts the following one back into the wrong index.
Array.from(td.childNodes).forEach((el) => {
if (el instanceof HTMLParagraphElement) {
tmp.append(...el.childNodes);
} else {
tmp.append(el);
switch (docname) {
case "Guide_to_chemistry":
chemistryFixups(root);
break;
default:
}
});
td.parentNode.replaceChild(tmp, td);
});
}
// Enrich "x part" with checkboxes and parts
Array.from(document.querySelectorAll("td"))
.filter((el) => el.textContent.indexOf(" part") >= 0)
.forEach((el) => {
el.innerHTML = el.innerHTML.replace(
/((\d+)\s+(?:parts?|units?))(.*?(?:<\/a>|\n|$))/gi,
(match, ...m) =>
`<label class="bgus_part ${
m[2].includes("</a>") ? "bgus_part_tooltip" : ""
}" data-amount="${
m[1]
}"><input type="checkbox" class='bgus_checkbox bgus_hidden'/> <span class="bgus_part_label" data-src="${
m[0]
}">${m[0]}</span></label>${m[2].replace(
/(<a .+?<\/a>)/gi,
'<span class="bgus_nobreak bgus_nested_element">$1<span class="bgus_twistie"></span></span>'
)}`
);
});
function chemistryScript(root: HTMLElement) {
// Add event to autofill child checkboxes
root
.querySelectorAll(".bgus_part_tooltip > .bgus_checkbox")
@ -170,18 +266,6 @@ export default function userscript(root: HTMLElement, docname: string): void {
});
});
// Wrap every recipe with extra metadata
root.querySelectorAll<HTMLElement>(".bgus_part").forEach((el) => {
if ("parts" in el.parentElement.dataset) {
el.parentElement.dataset.parts = (
parseInt(el.parentElement.dataset.parts, 10) +
parseInt(el.dataset.amount, 10)
).toString();
} else {
el.parentElement.dataset.parts = el.dataset.amount;
}
});
const setPartSize = (labels, ml) => {
labels.forEach((el) => {
const part = el.parentElement.dataset.amount;
@ -209,104 +293,21 @@ export default function userscript(root: HTMLElement, docname: string): void {
});
};
root.classList.add("bchem");
// Init fuzzy search with elements
const el = Array.from(
root.querySelectorAll<HTMLElement>(
"table.wikitable > tbody > tr:not(:first-child) > th"
)
);
const name = el.map((elem) => {
let partial = "";
elem.childNodes.forEach((t) => {
if (t instanceof Text) {
partial += t.textContent;
}
});
return partial.trim();
});
const name = el.map((elem) =>
elem.querySelector(".reagent-header").textContent.trim().replace("▮", "")
);
const box = searchBox(
el,
name.map((e, i) => ({ id: i, str: e }))
);
document.body.appendChild(box);
// Remove "Removed medicines" section
const remTable = root.querySelector(
"#Non-craftable_Medicines + h4 + p + table"
);
remTable.parentElement.removeChild(remTable);
root
.querySelectorAll<HTMLElement>("div[data-name] .wikitable.sortable tr")
.forEach((row) => {
const sectionEl = findParent(
row,
(sel) => "name" in sel.dataset && sel.dataset.name !== ""
);
const section = sectionEl.dataset.name;
if (row.querySelector("td") === null) {
// Remove unused rows if found
const headers = row.querySelectorAll("th");
headers.forEach((th, i) => {
if (i < 2) {
th.classList.add("table-head");
return;
}
th.parentElement.removeChild(th);
});
return;
}
const rows = Array.from(row.querySelectorAll("td")).slice(1);
let treatment = null;
let desc = null;
let metabolism = null;
let overdose = null;
let addiction = null;
// Handle special cases
switch (section) {
case "Components":
case "Virology Recipes":
[desc] = rows;
break;
case "Narcotics":
[desc, metabolism, overdose, addiction] = rows;
break;
case "Explosive Strength":
case "Other Reagents":
case "Mutation Toxins":
[desc, metabolism] = rows;
break;
default:
// All fields
[treatment, desc, metabolism, overdose, addiction] = rows;
}
const title = row.querySelector("th");
let content = `<div class="reagent-header">${title.innerHTML}</div>`;
if (treatment) {
content += `<p class="treatment">${treatment.innerHTML}</p>`;
}
if (metabolism) {
content += `<p class="metabolism">${metabolism.innerHTML}</p>`;
}
if (addiction && addiction.innerHTML.trim() !== "N/A") {
content += `<p class="addiction">${addiction.innerHTML}</p>`;
}
if (overdose && overdose.innerHTML.trim() !== "N/A") {
content += `<p class="overdose">${overdose.innerHTML}</p>`;
}
if (desc) {
content += `<p>${desc.innerHTML}</p>`;
}
title.classList.add("reagent-ext");
title.innerHTML = content;
if (desc) desc.parentElement.removeChild(desc);
if (treatment) treatment.parentElement.removeChild(treatment);
if (metabolism) metabolism.parentElement.removeChild(metabolism);
if (overdose) overdose.parentElement.removeChild(overdose);
if (addiction) addiction.parentElement.removeChild(addiction);
});
document.body.addEventListener("keydown", (ev) => {
if (ev.shiftKey) {
switch (ev.keyCode) {
@ -348,9 +349,9 @@ export default function userscript(root: HTMLElement, docname: string): void {
}
}
});
}
}
function betterGeneric() {
function genericScript(root: HTMLElement) {
const el = Array.from(
root.querySelectorAll<HTMLElement>("div.mw-headline-cont[id][data-name]")
);
@ -363,16 +364,21 @@ export default function userscript(root: HTMLElement, docname: string): void {
{ alignment: "start" }
);
root.appendChild(box);
}
}
export function bindFunctions(root: HTMLElement, docname: string): void {
switch (docname) {
case "Guide_to_chemistry":
betterChemistry();
chemistryScript(root);
break;
default:
betterGeneric();
genericScript(root);
break;
}
// Everything is loaded, remove loading bar
statusMessage.innerHTML = "";
}
export default {
CURRENT_VERSION,
processHTML,
bindFunctions,
};

View file

@ -12,4 +12,8 @@ export function findParent(
return parent;
}
export default { findParent };
export function nextAnimationFrame(): Promise<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
export default { findParent, nextAnimationFrame };

View file

@ -66,6 +66,7 @@ body {
overflow: hidden;
.page {
//visibility: hidden;
will-change: display;
display: none;
overflow-y: scroll;
grid-row: 1;

View file

@ -2,6 +2,7 @@
"compilerOptions": {
"target": "ES2020",
"baseUrl": ".",
"allowSyntheticDefaultImports": true,
"paths": {
"~*": ["./src/*"]
}

View file

@ -3230,6 +3230,11 @@ icss-replace-symbols@1.1.0, icss-replace-symbols@^1.1.0:
resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded"
integrity sha1-Bupvg2ead0njhs/h/oEq5dsiPe0=
idb-keyval@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/idb-keyval/-/idb-keyval-3.2.0.tgz#cbbf354deb5684b6cdc84376294fc05932845bd6"
integrity sha512-slx8Q6oywCCSfKgPgL0sEsXtPVnSbTLWpyiDcu6msHOyKOLari1TD1qocXVCft80umnkk3/Qqh3lwoFt8T/BPQ==
ieee754@^1.1.4:
version "1.1.13"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"