diff --git a/assets/javascripts/app/db.js b/assets/javascripts/app/db.js index 64e69d9542..862258d3f8 100644 --- a/assets/javascripts/app/db.js +++ b/assets/javascripts/app/db.js @@ -178,7 +178,7 @@ app.DB = class DB { } } - store(doc, data, onSuccess, onError, _retry) { + store(doc, data, mtime, onSuccess, onError, _retry) { if (_retry == null) { _retry = true; } @@ -188,24 +188,42 @@ app.DB = class DB { return; } - const txn = this.idbTransaction(db, { - stores: ["docs", doc.slug], - mode: "readwrite", - ignoreError: false, - }); + const retry = () => { + this.migrate(); + setTimeout(() => { + return this.store(doc, data, mtime, onSuccess, onError, false); + }, 0); + }; + + let txn; + try { + txn = this.idbTransaction(db, { + stores: ["docs", doc.slug], + mode: "readwrite", + ignoreError: false, + }); + } catch (error) { + // The object store doesn't exist yet, which happens when the doc was + // enabled while the database was being opened. Bumping the schema + // creates it (see onUpgradeNeeded). + if (error.name === "NotFoundError" && _retry) { + retry(); + } else { + onError(error); + } + return; + } + txn.oncomplete = () => { if (this.cachedDocs != null) { - this.cachedDocs[doc.slug] = doc.mtime; + this.cachedDocs[doc.slug] = mtime; } onSuccess(); }; txn.onerror = (event) => { event.preventDefault(); if (txn.error?.name === "NotFoundError" && _retry) { - this.migrate(); - setTimeout(() => { - return this.store(doc, data, onSuccess, onError, false); - }, 0); + retry(); } else { onError(event); } @@ -219,7 +237,7 @@ app.DB = class DB { } store = txn.objectStore("docs"); - store.put(doc.mtime, doc.slug); + store.put(mtime, doc.slug); }); } @@ -264,6 +282,52 @@ app.DB = class DB { }); } + // Reads back everything that store() wrote for a doc: its pages and the + // mtime it was installed with. Calls back with null when the doc isn't + // installed or can't be read. + dump(doc, callback) { + this.db((db) => { + if (!db || !db.objectStoreNames.contains(doc.slug)) { + callback(null); + return; + } + + const txn = this.idbTransaction(db, { + stores: ["docs", doc.slug], + mode: "readonly", + ignoreError: false, + ignoreAbort: false, + }); + const data = {}; + let failed = false; + let mtime = null; + + txn.oncomplete = () => callback(failed || !mtime ? null : { mtime, data }); + txn.onerror = function (event) { + event.preventDefault(); + failed = true; + }; + txn.onabort = function (event) { + event.preventDefault(); + callback(null); + }; + + txn.objectStore("docs").get(doc.slug).onsuccess = (event) => { + mtime = event.target.result; + }; + + const req = txn.objectStore(doc.slug).openCursor(); + req.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) { + return; + } + data[cursor.key] = cursor.value; + cursor.continue(); + }; + }); + } + version(doc, fn) { const version = this.cachedVersion(doc); if (version != null) { diff --git a/assets/javascripts/app/offline_backup.js b/assets/javascripts/app/offline_backup.js new file mode 100644 index 0000000000..9034a90595 --- /dev/null +++ b/assets/javascripts/app/offline_backup.js @@ -0,0 +1,207 @@ +// 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 +// restore a backup after the browser evicted the data, or to move the +// documentations to another computer without downloading them again. +app.OfflineBackup = class OfflineBackup { + static TYPE = "devdocs-offline"; + static VERSION = 1; + static MIME_TYPE = "application/json"; + + filename(docs) { + const date = new Date().toISOString().slice(0, 10); + const name = docs.length === 1 ? docs[0].slug : "offline"; + return `devdocs-${name}-${date}.json`; + } + + // Calls back with a Blob containing every installed doc among `docs`, and + // the number of docs it holds. Docs that aren't installed are skipped. + export(docs, onProgress, onSuccess, onError) { + const chunks = [ + `{"type":"${OfflineBackup.TYPE}","version":${ + OfflineBackup.VERSION + },"date":"${new Date().toISOString()}","docs":[`, + ]; + let count = 0; + let i = 0; + + var next = () => { + const doc = docs[i++]; + + if (!doc) { + if (count === 0) { + onError("empty"); + return; + } + chunks.push("]}"); + onSuccess(new Blob(chunks, { type: OfflineBackup.MIME_TYPE }), count); + return; + } + + onProgress(doc, i, docs.length); + app.db.dump(doc, (result) => { + if (result) { + // 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)), + ); + } + setTimeout(next, 0); + }); + }; + + next(); + } + + serializeDoc(doc, result) { + 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]; + } + return entry; + } + + import(file, onProgress, onSuccess, onError) { + if (!file || (file.type && file.type !== OfflineBackup.MIME_TYPE)) { + onError("invalid"); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + const data = (() => { + try { + return JSON.parse(reader.result); + } catch (error) {} + })(); + + if (!data || data.type !== OfflineBackup.TYPE || !Array.isArray(data.docs)) { + onError("invalid"); + return; + } + if (data.version > OfflineBackup.VERSION) { + onError("version"); + return; + } + + this.importDocs(data.docs, onProgress, onSuccess, onError); + }; + reader.onerror = () => onError("invalid"); + reader.readAsText(file); + } + + importDocs(entries, onProgress, onSuccess, onError) { + const queue = []; + const skipped = []; + + for (var entry of entries) { + var doc = this.isValidEntry(entry) && this.findDoc(entry.slug); + if (doc) { + queue.push([doc, entry]); + } else { + skipped.push(typeof entry?.slug === "string" ? entry.slug : "?"); + } + } + + if (queue.length === 0) { + onError("unknown", skipped); + return; + } + + const enabled = this.enableDocs(queue.map(([doc]) => doc)); + const total = queue.length; + const imported = []; + const failed = []; + let i = 0; + + var next = () => { + const item = queue[i++]; + + if (!item) { + onSuccess({ docs: imported, skipped, failed, enabled }); + return; + } + + const [doc, entry] = item; + const mtime = entry.mtime; + onProgress(doc, i, total); + + app.db.store( + doc, + entry.db, + 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]); + } + imported.push(doc); + setTimeout(next, 0); + }, + () => { + failed.push(doc.slug); + setTimeout(next, 0); + }, + ); + }; + + next(); + } + + // Storing a doc clears whatever was installed before it, so an entry that + // isn't usable has to be rejected rather than wipe a working installation. + // The index page is what DB#checkForCorruptedDocs looks for. + isValidEntry(entry) { + return ( + entry != null && + typeof entry.slug === "string" && + Number.isSafeInteger(entry.mtime) && + entry.mtime > 0 && + entry.db?.constructor === Object && + typeof entry.db.index === "string" && + entry.db.index.length > 0 + ); + } + + isValidIndex(index) { + return ( + index?.constructor === Object && + Array.isArray(index.entries) && + Array.isArray(index.types) + ); + } + + findDoc(slug) { + return ( + app.docs.findBy("slug", slug) || app.disabledDocs.findBy("slug", slug) + ); + } + + // Enabling the docs up-front is what makes their object stores exist: the + // schema bump triggers DB#onUpgradeNeeded, which only creates stores for the + // enabled docs. Returns the number of docs that weren't enabled before. + enableDocs(docs) { + let enabled = 0; + + for (var doc of docs) { + if (app.docs.contains(doc)) { + continue; + } + app.disabledDocs.remove(doc); + app.docs.add(doc); + enabled += 1; + } + + if (enabled > 0) { + app.docs.sort(); + app.saveDocs(); + } + + return enabled; + } +}; diff --git a/assets/javascripts/lib/util.js b/assets/javascripts/lib/util.js index 555c129e33..1e4ff320c0 100644 --- a/assets/javascripts/lib/util.js +++ b/assets/javascripts/lib/util.js @@ -468,6 +468,19 @@ $.classify = function (string) { $.noop = function () {}; +$.download = function (blob, filename) { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.style.display = "none"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + // The URL has to stay alive until the browser has picked up the download. + setTimeout(() => URL.revokeObjectURL(url), 1000); +}; + $.popup = function (value) { try { window.open(value.href || value, "_blank", "noopener"); diff --git a/assets/javascripts/models/doc.js b/assets/javascripts/models/doc.js index 4e726fcde3..0a5c815a85 100644 --- a/assets/javascripts/models/doc.js +++ b/assets/javascripts/models/doc.js @@ -155,7 +155,7 @@ app.models.Doc = class Doc extends app.Model { const success = (data) => { this.installing = null; - app.db.store(this, data, onSuccess, error); + app.db.store(this, data, this.mtime, onSuccess, error); }; ajax({ diff --git a/assets/javascripts/templates/pages/offline_tmpl.js b/assets/javascripts/templates/pages/offline_tmpl.js index 0ecac8c3ea..2f6f9e7a2d 100644 --- a/assets/javascripts/templates/pages/offline_tmpl.js +++ b/assets/javascripts/templates/pages/offline_tmpl.js @@ -8,7 +8,7 @@ app.templates.offlinePage = (docs, hasPersistence, isPersistent) => `\ }>Install updates automatically
${exception.name}: ${exception.message}`
@@ -106,11 +152,11 @@ app.templates.offlineDoc = function (doc, status) {
: outdated
? `\
${html}`; } } diff --git a/assets/javascripts/views/content/settings_page.js b/assets/javascripts/views/content/settings_page.js index 46cacb3c16..d8cee94889 100644 --- a/assets/javascripts/views/content/settings_page.js +++ b/assets/javascripts/views/content/settings_page.js @@ -67,13 +67,7 @@ app.views.SettingsPage = class SettingsPage extends app.View { const data = new Blob([JSON.stringify(app.settings.export())], { type: "application/json", }); - const link = document.createElement("a"); - link.href = URL.createObjectURL(data); - link.download = "devdocs.json"; - link.style.display = "none"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + $.download(data, "devdocs.json"); } import(file, input) { diff --git a/assets/stylesheets/components/_content.scss b/assets/stylesheets/components/_content.scss index e4dd033f37..10dd061dfb 100644 --- a/assets/stylesheets/components/_content.scss +++ b/assets/stylesheets/components/_content.scss @@ -288,6 +288,7 @@ flex: 0 0 auto; margin: .5rem 0; padding: .25rem 0; + white-space: nowrap; @extend %box; ._btn-link { @@ -443,8 +444,17 @@ } ._file-btn { + display: inline-block; position: relative; overflow: hidden; + cursor: pointer; + + // The input is hidden with opacity rather than visibility so that it stays + // in the focus order; the label shows the focus ring on its behalf. + &:focus-within { + outline: 1px dotted; + outline: -webkit-focus-ring-color auto 5px; + } > input { position: absolute; @@ -452,7 +462,8 @@ left: 0; width: 100%; height: 100%; - visibility: hidden; + opacity: 0; + cursor: pointer; } }