Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 16 additions & 16 deletions assets/javascripts/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Shortcuts } from "./shortcuts.js";
import { UpdateChecker } from "./update_checker.js";
import { Docs } from "../collections/docs.js";
import { Entries } from "../collections/entries.js";
import { CookiesStore } from "../lib/cookies_store.js";
import { SettingsStore } from "../lib/settings_store.js";
import { Events } from "../lib/events.js";
import { LocalStorageStore } from "../lib/local_storage_store.js";
import { $ } from "../lib/util.js";
Expand Down Expand Up @@ -166,7 +166,7 @@ export class App extends Events {
}
this.previousErrorHandler = onerror;
window.onerror = this.onWindowError.bind(this);
CookiesStore.onBlocked = this.onCookieBlocked;
SettingsStore.onBlocked = this.onStorageBlocked;
}
}

Expand Down Expand Up @@ -414,13 +414,13 @@ export class App extends Events {

/** Drops the cached indexes and reloads the app. */
reload() {
this.docs.clearCache();
this.disabledDocs.clearCache();
if (this.serviceWorker) {
this.serviceWorker.reload();
} else {
this.reboot();
}
this.db.clearIndexes(() => {
if (this.serviceWorker) {
this.serviceWorker.reload();
} else {
this.reboot();
}
});
}

/** Clears every trace of the app and returns to the index. */
Expand Down Expand Up @@ -477,27 +477,27 @@ export class App extends Events {
}

/**
* Warns the user that cookies are blocked, so preferences won't stick. Once.
* Warns the user that storage is blocked, so preferences won't stick. Once.
*
* @param {string} key
* @param {unknown} value What was written.
* @param {unknown} actual What was read back.
*/
onCookieBlocked(key, value, actual) {
if (this.cookieBlocked) {
onStorageBlocked(key, value, actual) {
if (this.storageBlocked) {
return;
}
this.cookieBlocked = true;
new Notif("CookieBlocked", { autoHide: null });
Raven.captureMessage(`CookieBlocked/${key}`, {
this.storageBlocked = true;
new Notif("StorageBlocked", { autoHide: null });
Raven.captureMessage(`StorageBlocked/${key}`, {
level: "warning",
extra: { value, actual },
});
}

/** @param {...unknown} args The `window.onerror` arguments. */
onWindowError(...args) {
if (this.cookieBlocked) {
if (this.storageBlocked) {
return;
}
if (this.isAppError(args[0], /** @type {string} */ (args[1]))) {
Expand Down
167 changes: 162 additions & 5 deletions assets/javascripts/app/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ export class DB {
static NAME = "docs";
static VERSION = 15;

/**
* The docs' entry indexes, by slug, as `[mtime, index]`. Not one store per
* doc: a doc's index is cached before it is enabled (see App#enableDoc), so
* a store of its own wouldn't exist yet. And not inside the doc's own store
* either, where `index` is the doc's home page and DB#store clears
* everything it holds on install.
*/
static INDEXES_STORE = "indexes";

/** Probes for IndexedDB support and prepares the callback queue. */
constructor() {
this.versionMultipler = $.isIE() ? 1e5 : 1e9;
Expand Down Expand Up @@ -240,7 +249,8 @@ export class DB {
}

/**
* Creates an object store per enabled doc.
* Creates an object store per enabled doc, plus the two the app keeps for
* itself: the installed docs' mtimes, and the cached entry indexes.
*
* @param {IDBVersionChangeEvent} event
*/
Expand All @@ -253,10 +263,12 @@ export class DB {

const objectStoreNames = $.makeArray(db.objectStoreNames);

if (!$.arrayDelete(objectStoreNames, "docs")) {
try {
db.createObjectStore("docs");
} catch (error) {}
for (var store of ["docs", DB.INDEXES_STORE]) {
if (!$.arrayDelete(objectStoreNames, store)) {
try {
db.createObjectStore(store);
} catch (error) {}
}
}

for (var doc of app.docs.all()) {
Expand Down Expand Up @@ -482,6 +494,151 @@ export class DB {
});
}

/**
* Runs `fn` with the indexes store, or with nothing when the database can't
* hand it over — unavailable, or old enough to predate the store, in which
* case the schema is bumped so that the next open creates it.
*
* @param {IDBTransactionMode} mode
* @param {(store?: IDBObjectStore) => void} fn
*/
indexes(mode, fn) {
this.db((db) => {
let store;
if (db) {
try {
store = this.idbTransaction(db, {
stores: [DB.INDEXES_STORE],
mode,
}).objectStore(DB.INDEXES_STORE);
} catch (error) {
if (error?.name === "NotFoundError") {
this.migrate();
}
}
}
fn(store);
});
}

/**
* Reads a doc's cached entry index.
*
* @param {Doc} doc
* @param {number} mtime The build to read it for; one cached for an earlier
* build is passed over, and overwritten when the doc is fetched again.
* @param {(index?: unknown) => void} fn Called with the index, or with
* nothing when there isn't a usable one. Never before `loadIndex` returns;
* Doc#load and its callers rely on it.
*/
loadIndex(doc, mtime, fn) {
this.indexes("readonly", (store) => {
const req = store?.get(doc.slug);
if (!req) {
// `db` runs its callback there and then when IndexedDB is off, and
// Docs#load can't be called back before `Doc#load` has returned.
const index = this.importIndex(doc, mtime);
setTimeout(() => fn(index), 0);
return;
}

req.onsuccess = () => {
const cached = req.result;
fn(cached?.[0] === mtime ? cached[1] : this.importIndex(doc, mtime));
};
req.onerror = function (event) {
event.preventDefault();
fn();
};
});
}

/**
* @param {Doc} doc
* @param {number} mtime The build the index was fetched for.
* @param {unknown} index
* @param {() => void} [fn] Called once the write has been committed, and not
* at all when there was nowhere to write it.
*/
storeIndex(doc, mtime, index, fn) {
this.indexes("readwrite", (store) => {
if (!store) {
return;
}
store.put([mtime, index], doc.slug);
if (fn) {
store.transaction.oncomplete = fn;
}
});
}

/** @param {Doc} doc */
deleteIndex(doc) {
this.indexes("readwrite", (store) => store?.delete(doc.slug));
}

/**
* Drops every cached index, including any an earlier version of the app left
* in localStorage and hasn't been asked for yet.
*
* @param {() => void} fn Called once they are gone — and called even when the
* transaction doesn't go through, so that a caller waiting to reload does.
*/
clearIndexes(fn) {
for (var doc of app.docs.all().concat(app.disabledDocs.all())) {
app.localStorage.del(doc.slug);
}

this.indexes("readwrite", (store) => {
if (!store) {
fn();
return;
}

store.clear();
const txn = store.transaction;
const done = () => {
txn.oncomplete = txn.onerror = txn.onabort = null;
fn();
};
txn.oncomplete = done;
txn.onerror = txn.onabort = (event) => {
event.preventDefault();
done();
};
});
}

/**
* Moves an index an earlier version of the app cached in localStorage into
* the database, on the read that goes looking for it — nothing is moved for
* a doc that is never loaded, and nothing holds up the boot.
*
* Remove once the app has had a release or two to empty localStorage out.
*
* @param {Doc} doc
* @param {number} mtime
* @returns {unknown} The index, when localStorage held a current one.
*/
importIndex(doc, mtime) {
const cached = app.localStorage.get(doc.slug);
if (!Array.isArray(cached)) {
return;
}

if (cached[0] !== mtime) {
app.localStorage.del(doc.slug);
return;
}

// localStorage holds the only copy until the write lands, and there may be
// nowhere to write it yet: a database that predates the indexes store only
// queues its schema bump when it first misses it, and a browser without
// IndexedDB never has one.
this.storeIndex(doc, mtime, cached[1], () => app.localStorage.del(doc.slug));
return cached[1];
}

/**
* @param {Doc} doc
* @returns {number | false | undefined} `undefined` when the cache isn't loaded yet.
Expand Down
37 changes: 22 additions & 15 deletions assets/javascripts/app/offline_backup.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ import { app } from "./app.js";
*/

/**
* Exports the offline data (the pages stored in IndexedDB and the index files
* cached in localStorage) to a JSON file, and imports it back — either to
* Exports the offline data (the pages and the index files, both in IndexedDB)
* to a JSON file, and imports it back — either to
* restore a backup after the browser evicted the data, or to move the
* documentations to another computer without downloading them again.
*/
Expand Down Expand Up @@ -78,15 +78,24 @@ export class OfflineBackup {

onProgress(doc, i, docs.length);
app.db.dump(doc, (result) => {
if (result) {
if (!result) {
setTimeout(next, 0);
return;
}

// Ship the index file too, so that the doc can be used on a computer
// that never downloaded it (the app falls back to the network
// otherwise). Asking for it by the stored mtime leaves behind one that
// belongs to a different build.
app.db.loadIndex(doc, result.mtime, (index) => {
// Serialize each doc on its own instead of building one big object,
// to avoid holding the whole backup in memory twice.
chunks.push(
(count++ === 0 ? "" : ",") +
JSON.stringify(this.serializeDoc(doc, result)),
JSON.stringify(this.serializeDoc(doc, result, index)),
);
}
setTimeout(next, 0);
setTimeout(next, 0);
});
});
};

Expand All @@ -96,15 +105,13 @@ export class OfflineBackup {
/**
* @param {Doc} doc
* @param {{ mtime: number, data: unknown }} result The doc's stored database.
* @param {unknown} [index] The doc's entry index, when one was cached.
* @returns {unknown} One entry of the backup's `docs` array.
*/
serializeDoc(doc, result) {
serializeDoc(doc, result, index) {
const entry = { slug: doc.slug, mtime: result.mtime, db: result.data };
const index = app.localStorage.get(doc.slug);
// Ship the index file too, so that the doc can be used on a computer that
// never downloaded it (the app falls back to the network otherwise).
if (index && index[0] === result.mtime) {
entry.index = index[1];
if (index !== undefined) {
entry.index = index;
}
return entry;
}
Expand Down Expand Up @@ -197,9 +204,9 @@ export class OfflineBackup {
mtime,
() => {
if (this.isValidIndex(entry.index)) {
// Keyed by the backup's mtime so that Doc#_getCache discards it
// when the doc has been updated since the backup was made.
app.localStorage.set(doc.slug, [mtime, entry.index]);
// Keyed by the backup's mtime so that the store discards it when
// the doc has been updated since the backup was made.
app.db.storeIndex(doc, mtime, entry.index);
}
imported.push(doc);
setTimeout(next, 0);
Expand Down
10 changes: 5 additions & 5 deletions assets/javascripts/app/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

import { app } from "./app.js";
import { config } from "./config.js";
import { CookiesStore } from "../lib/cookies_store.js";
import { settingsStore } from "../lib/settings_store.js";
import { $ } from "../lib/util.js";

/**
* A setting the user turns on or off.
*
* It is a boolean going in, but CookiesStore writes `true` as `1` and parses
* It is a boolean going in, but SettingsStore writes `true` as `1` and parses
* the digit back out on read, so it comes back as a number. A setting that was
* never written falls back to its default, which is a real boolean. Both are
* truthy or falsy as intended; only a strict comparison would go wrong.
Expand Down Expand Up @@ -47,7 +47,7 @@ import { $ } from "../lib/util.js";
*/

/**
* The user's preferences, stored in cookies so that the server can read them.
* The user's preferences, stored in localStorage.
*
* `PREFERENCE_KEYS` are the ones the user controls and that a backup carries;
* `INTERNAL_KEYS` are the app's own bookkeeping and stay out of backups.
Expand Down Expand Up @@ -99,9 +99,9 @@ export class Settings {
autoLatestVersion: false,
};

/** Opens the cookie store and starts following the system colour scheme. */
/** Opens the store and starts following the system colour scheme. */
constructor() {
this.store = new CookiesStore();
this.store = settingsStore;
this.cache = {};
this.autoSupported =
window.matchMedia("(prefers-color-scheme)").media !== "not all";
Expand Down
7 changes: 0 additions & 7 deletions assets/javascripts/collections/docs.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,6 @@ export class Docs extends Collection {
}
}

/** Drops every doc's cached index. */
clearCache() {
for (var doc of this.models) {
doc.clearCache();
}
}

/**
* Removes every doc's offline database, one at a time.
*
Expand Down
Loading
Loading