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 @@ -23,6 +23,7 @@ app.templates.offlinePage = (docs, hasPersistence, isPersistent) => `\ ${docs} +
${offlinePersistenceNote(hasPersistence, isPersistent)}
@@ -33,6 +34,8 @@ app.templates.offlinePage = (docs, hasPersistence, isPersistent) => `\ The app also uses Service Workers and localStorage to cache the assets and index files.
Can I close the tab/browser?
${canICloseTheTab()} +
How do I move the documentations to another computer? +
Export them to a file using the buttons above, copy it over, and import it there. The other computer still needs to load DevDocs once while online for the app itself to be cached.
What if I don't update a documentation?
You'll see outdated content and some pages will be missing or broken, because the rest of the app (including data for the search and sidebar) uses a different caching mechanism that's updated automatically.
I found a bug, where do I report it? @@ -44,6 +47,49 @@ app.templates.offlinePage = (docs, hasPersistence, isPersistent) => `\ \ `; +app.templates.backupProgress = (action, doc, i, total) => + `${action} ${doc.fullName}\u2026 (${i}/${total})`; + +app.templates.backupExported = (count) => + `Exported ${count} ${pluralizeDocs(count)}.`; + +app.templates.backupImported = function (result) { + let html = `Imported ${result.docs.length} ${pluralizeDocs( + result.docs.length + )}.`; + + if (result.failed.length > 0) { + html += ` Couldn't be stored: ${listSlugs(result.failed)}.`; + } + if (result.skipped.length > 0) { + // The skipped slugs come from the imported file, hence the escaping. + html += ` Not available anymore: ${listSlugs(result.skipped)}.`; + } + if (result.enabled > 0) { + html += " Reloading\u2026"; + } + + return html; +}; + +app.templates.backupError = function (reason) { + switch (reason) { + case "empty": + return "No documentation is installed. Install one before exporting."; + case "unknown": + return "Nothing to import. This file doesn't contain any documentation that DevDocs still offers."; + case "version": + return "This file was exported by a newer version of DevDocs. Reload the app and try again."; + default: + return "The file you selected is invalid. Only files exported from this page can be imported."; + } +}; + +var pluralizeDocs = (count) => + count === 1 ? "documentation" : "documentations"; + +var listSlugs = (slugs) => slugs.map((slug) => $.escape(slug)).join(", "); + app.templates.persistenceError = function (exception) { const reason = exception ? `${exception.name}: ${exception.message}` @@ -106,11 +152,11 @@ app.templates.offlineDoc = function (doc, status) { : outdated ? `\ Outdated - - \ +\ ` : `\ Up‑to‑date -\ +\ `; return html + ""; diff --git a/assets/javascripts/views/content/offline_page.js b/assets/javascripts/views/content/offline_page.js index 10852485d9..1b811599d4 100644 --- a/assets/javascripts/views/content/offline_page.js +++ b/assets/javascripts/views/content/offline_page.js @@ -77,7 +77,9 @@ app.views.OfflinePage = class OfflinePage extends app.View { onClick(event) { let el = $.eventTarget(event); let action = el.getAttribute("data-action"); - if (action) { + if (action === "export") { + this.exportDoc(this.docByEl(el), el); + } else if (action) { const doc = this.docByEl(el); if (action === "update") { action = "install"; @@ -102,6 +104,8 @@ app.views.OfflinePage = class OfflinePage extends app.View { } } else if (el.hasAttribute("data-enable-persistence")) { this.requestPersistence(); + } else if (el.hasAttribute("data-export-docs")) { + this.exportDocs(app.docs.all()); } } @@ -149,6 +153,109 @@ app.views.OfflinePage = class OfflinePage extends app.View { onChange(event) { if (event.target.name === "autoUpdate") { app.settings.set("manualUpdate", !event.target.checked); + } else if (event.target.name === "importDocs") { + this.importDocs(event.target); + } + } + + backup() { + return this._backup || (this._backup = new app.OfflineBackup()); + } + + // Exports `docs` into a single file. Returns false when another backup is + // already running, in which case `onDone` is never called. + exportDocs(docs, onDone) { + if (this.backingUp) { + return false; + } + this.backingUp = true; + const backup = this.backup(); + + const done = (html, isError, success) => { + this.backingUp = false; + if (!this.activated) { + return; + } + this.setBackupStatus(html, isError); + if (onDone) { + onDone(success); + } + }; + + backup.export( + docs, + (doc, i, total) => + this.setBackupStatus( + this.tmpl("backupProgress", "Exporting", doc, i, total), + ), + (blob, count) => { + $.download(blob, backup.filename(docs)); + done(this.tmpl("backupExported", count), false, true); + }, + () => done(this.tmpl("backupError", "empty"), true, false), + ); + + return true; + } + + exportDoc(doc, el) { + const started = this.exportDocs([doc], (success) => + success ? this.onInstallSuccess(doc) : this.onInstallError(doc), + ); + if (started) { + el.parentNode.innerHTML = "Exporting\u2026"; + } + } + + importDocs(input) { + const file = input.files[0]; + input.value = ""; // so that picking the same file again fires a change event + + if (this.backingUp) { + return; + } + this.backingUp = true; + + this.backup().import( + file, + (doc, i, total) => + this.setBackupStatus( + this.tmpl("backupProgress", "Importing", doc, i, total), + ), + (result) => { + this.backingUp = false; + // Newly enabled docs have no index in memory, so the session stays + // inconsistent until the app reboots, whether the page is still + // being shown or not. + if (result.enabled > 0) { + this.delay(() => app.reboot(), this.activated ? 2000 : 0); + } + if (!this.activated) { + return; + } + this.setBackupStatus( + this.tmpl("backupImported", result), + result.failed.length > 0, + ); + // Nothing was enabled: refresh the rows that changed, which keeps + // the message a re-render would wipe. + if (result.enabled === 0) { + for (var doc of result.docs) { + this.onInstallSuccess(doc); + } + } + }, + (reason) => { + this.backingUp = false; + this.setBackupStatus(this.tmpl("backupError", reason), true); + }, + ); + } + + setBackupStatus(html, isError) { + const el = this.find("#_offline-backup-status"); + if (el) { + el.innerHTML = `

${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; } }