From b8a154b92aa579f1c13c998f5706aa02196fbb8d Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 08:58:06 +0200 Subject: [PATCH 01/11] Extract the file download logic into $.download Also revoke the object URL once the browser has picked up the download, which the settings export never did. --- assets/javascripts/lib/util.js | 13 +++++++++++++ assets/javascripts/views/content/settings_page.js | 8 +------- 2 files changed, 14 insertions(+), 7 deletions(-) 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/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) { From 12d556b080f66ba48eb16cdfc8e8ad821139cf2a Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 08:58:09 +0200 Subject: [PATCH 02/11] Let DB#store take an explicit mtime and add DB#dump DB#dump reads a doc's pages and installed mtime back out of IndexedDB, and store() can now write a mtime other than the doc's current one, both of which are needed to export and restore offline data. store() also handles the NotFoundError that db.transaction() throws synchronously when the object store doesn't exist yet, which happens when a doc is enabled while the database is being opened; only the equivalent error event was handled before. --- assets/javascripts/app/db.js | 88 +++++++++++++++++++++++++++----- assets/javascripts/models/doc.js | 2 +- 2 files changed, 77 insertions(+), 13 deletions(-) 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/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({ From 8ebe5bbef667f980797f0ee9f0be4387c4257fda Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 08:58:13 +0200 Subject: [PATCH 03/11] Add export and import of offline documentation The offline page can now save installed documentations to a JSON file and restore them later or on another computer, without downloading them again. Single documentations are exported from the action column, all of them at once with the new Export all button; Import restores either kind of file. The file holds each doc's pages, the index file cached in localStorage, and the mtime the doc was installed with, so that a restored doc that has been updated since shows up as outdated instead of up-to-date. Importing enables the docs that aren't enabled yet, which is also what creates their object stores, and reloads the app so their indexes get loaded. Closes #336 --- assets/javascripts/app/offline_backup.js | 185 ++++++++++++++++++ .../templates/pages/offline_tmpl.js | 49 ++++- .../javascripts/views/content/offline_page.js | 104 +++++++++- assets/stylesheets/components/_content.scss | 3 + 4 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 assets/javascripts/app/offline_backup.js diff --git a/assets/javascripts/app/offline_backup.js b/assets/javascripts/app/offline_backup.js new file mode 100644 index 0000000000..93f255562d --- /dev/null +++ b/assets/javascripts/app/offline_backup.js @@ -0,0 +1,185 @@ +// 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.onloadend = () => { + 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 = entry?.db && this.findDoc(entry.slug); + if (doc) { + queue.push([doc, entry]); + } else { + skipped.push(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 || doc.mtime; + onProgress(doc, i, total); + + if (entry.index) { + // Keyed by the backup's mtime so that Doc#_getCache discards it when + // the documentation has been updated since the backup was made. + app.localStorage.set(doc.slug, [mtime, entry.index]); + } + + app.db.store( + doc, + entry.db, + mtime, + () => { + imported.push(doc); + setTimeout(next, 0); + }, + () => { + failed.push(doc.slug); + setTimeout(next, 0); + }, + ); + }; + + next(); + } + + 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/templates/pages/offline_tmpl.js b/assets/javascripts/templates/pages/offline_tmpl.js index 0ecac8c3ea..0db58fb676 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,46 @@ 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: ${result.failed.join(", ")}.`; + } + if (result.skipped.length > 0) { + html += ` Not available anymore: ${result.skipped.join(", ")}.`; + } + 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"; + app.templates.persistenceError = function (exception) { const reason = exception ? `${exception.name}: ${exception.message}` @@ -106,11 +149,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..231a097cfd 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,104 @@ 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) { + if (this.backingUp) { + return; + } + this.backingUp = true; + const file = input.files[0]; + input.value = ""; // so that picking the same file again fires a change event + + this.backup().import( + file, + (doc, i, total) => + this.setBackupStatus( + this.tmpl("backupProgress", "Importing", doc, i, total), + ), + (result) => { + this.backingUp = false; + if (!this.activated) { + return; + } + this.setBackupStatus( + this.tmpl("backupImported", result), + result.failed.length > 0, + ); + // Newly enabled docs have no index in memory; reboot to load them. + // Otherwise just refresh the rows that changed, to keep the message. + if (result.enabled > 0) { + this.delay(() => app.reboot(), 2000); + } else { + 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/stylesheets/components/_content.scss b/assets/stylesheets/components/_content.scss index e4dd033f37..fadab17439 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,10 @@ } ._file-btn { + display: inline-block; position: relative; overflow: hidden; + cursor: pointer; > input { position: absolute; From 5bbf948fa6a3ec859b6e8fe1849b9619a8482825 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 09:14:26 +0200 Subject: [PATCH 04/11] Validate the entries of an imported backup Storing a doc clears whatever was installed before it, so an entry whose db isn't a plain object holding an index page, or whose mtime isn't a number, would wipe a working installation and then report it as installed. Reject those entries instead, along with indexes that aren't usable. --- assets/javascripts/app/offline_backup.js | 31 +++++++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/assets/javascripts/app/offline_backup.js b/assets/javascripts/app/offline_backup.js index 93f255562d..85ff453338 100644 --- a/assets/javascripts/app/offline_backup.js +++ b/assets/javascripts/app/offline_backup.js @@ -99,11 +99,11 @@ app.OfflineBackup = class OfflineBackup { const skipped = []; for (var entry of entries) { - var doc = entry?.db && this.findDoc(entry.slug); + var doc = this.isValidEntry(entry) && this.findDoc(entry.slug); if (doc) { queue.push([doc, entry]); } else { - skipped.push(entry?.slug || "?"); + skipped.push(typeof entry?.slug === "string" ? entry.slug : "?"); } } @@ -127,10 +127,10 @@ app.OfflineBackup = class OfflineBackup { } const [doc, entry] = item; - const mtime = entry.mtime || doc.mtime; + const mtime = entry.mtime; onProgress(doc, i, total); - if (entry.index) { + if (this.isValidIndex(entry.index)) { // Keyed by the backup's mtime so that Doc#_getCache discards it when // the documentation has been updated since the backup was made. app.localStorage.set(doc.slug, [mtime, entry.index]); @@ -154,6 +154,29 @@ app.OfflineBackup = class OfflineBackup { 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) From 3e4af59ffaa70904a5123f4bfd0aeb52abb9bb00 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 09:14:30 +0200 Subject: [PATCH 05/11] Escape the doc slugs reported by an import They come from the imported file and end up in the page through innerHTML. --- assets/javascripts/templates/pages/offline_tmpl.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/assets/javascripts/templates/pages/offline_tmpl.js b/assets/javascripts/templates/pages/offline_tmpl.js index 0db58fb676..fd578723f3 100644 --- a/assets/javascripts/templates/pages/offline_tmpl.js +++ b/assets/javascripts/templates/pages/offline_tmpl.js @@ -59,10 +59,11 @@ app.templates.backupImported = function (result) { )}.`; if (result.failed.length > 0) { - html += ` Couldn't be stored: ${result.failed.join(", ")}.`; + html += ` Couldn't be stored: ${listSlugs(result.failed)}.`; } if (result.skipped.length > 0) { - html += ` Not available anymore: ${result.skipped.join(", ")}.`; + // 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"; @@ -87,6 +88,8 @@ app.templates.backupError = function (reason) { 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}` From a1941feea2c9c887d725b2dea8b9166c8425317a Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 09:14:34 +0200 Subject: [PATCH 06/11] Parse an imported file from load instead of loadend loadend also fires after a failed read, which called back twice because onerror reports the failure already. --- assets/javascripts/app/offline_backup.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/javascripts/app/offline_backup.js b/assets/javascripts/app/offline_backup.js index 85ff453338..7c329dfcb9 100644 --- a/assets/javascripts/app/offline_backup.js +++ b/assets/javascripts/app/offline_backup.js @@ -72,7 +72,7 @@ app.OfflineBackup = class OfflineBackup { } const reader = new FileReader(); - reader.onloadend = () => { + reader.onload = () => { const data = (() => { try { return JSON.parse(reader.result); From 77a49498683dbf52becc9e89aca9f9669fcacb49 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 09:14:38 +0200 Subject: [PATCH 07/11] Cache an imported index only once its pages are stored Otherwise a failed transaction leaves the index of a doc that wasn't imported behind, which doesn't match the pages that are still installed. --- assets/javascripts/app/offline_backup.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/assets/javascripts/app/offline_backup.js b/assets/javascripts/app/offline_backup.js index 7c329dfcb9..9034a90595 100644 --- a/assets/javascripts/app/offline_backup.js +++ b/assets/javascripts/app/offline_backup.js @@ -130,17 +130,16 @@ app.OfflineBackup = class OfflineBackup { const mtime = entry.mtime; onProgress(doc, i, total); - if (this.isValidIndex(entry.index)) { - // Keyed by the backup's mtime so that Doc#_getCache discards it when - // the documentation has been updated since the backup was made. - app.localStorage.set(doc.slug, [mtime, entry.index]); - } - 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); }, From ff61f1ccdcf0836735b1c9a306e05d746c09e456 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 09:14:42 +0200 Subject: [PATCH 08/11] Clear the import input even when a backup is running The same file stayed selected, so picking it again once the running operation finished didn't fire a change event. --- assets/javascripts/views/content/offline_page.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/assets/javascripts/views/content/offline_page.js b/assets/javascripts/views/content/offline_page.js index 231a097cfd..d26f2d6d8c 100644 --- a/assets/javascripts/views/content/offline_page.js +++ b/assets/javascripts/views/content/offline_page.js @@ -208,12 +208,13 @@ app.views.OfflinePage = class OfflinePage extends app.View { } 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; - const file = input.files[0]; - input.value = ""; // so that picking the same file again fires a change event this.backup().import( file, From db663781f47d396f386ffa84b31d729a9e5b370c Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 09:14:48 +0200 Subject: [PATCH 09/11] Reboot after an import that enabled docs even if the page was left Those docs are in app.docs without their index being loaded, so the session is inconsistent until the app reboots, whether or not the offline page is still being shown. --- assets/javascripts/views/content/offline_page.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/assets/javascripts/views/content/offline_page.js b/assets/javascripts/views/content/offline_page.js index d26f2d6d8c..1b811599d4 100644 --- a/assets/javascripts/views/content/offline_page.js +++ b/assets/javascripts/views/content/offline_page.js @@ -224,6 +224,12 @@ app.views.OfflinePage = class OfflinePage extends app.View { ), (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; } @@ -231,11 +237,9 @@ app.views.OfflinePage = class OfflinePage extends app.View { this.tmpl("backupImported", result), result.failed.length > 0, ); - // Newly enabled docs have no index in memory; reboot to load them. - // Otherwise just refresh the rows that changed, to keep the message. - if (result.enabled > 0) { - this.delay(() => app.reboot(), 2000); - } else { + // 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); } From c701c4877e227fb903969aaa2b25dec6a7926e03 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 09:14:54 +0200 Subject: [PATCH 10/11] Keep the file input of a file button focusable visibility: hidden took it out of the focus order, leaving the Import and settings import buttons unusable with a keyboard. --- assets/stylesheets/components/_content.scss | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/assets/stylesheets/components/_content.scss b/assets/stylesheets/components/_content.scss index fadab17439..10dd061dfb 100644 --- a/assets/stylesheets/components/_content.scss +++ b/assets/stylesheets/components/_content.scss @@ -449,13 +449,21 @@ 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; top: 0; left: 0; width: 100%; height: 100%; - visibility: hidden; + opacity: 0; + cursor: pointer; } } From 49ead06825e34a0ccacd54b77596deef1bfe0c85 Mon Sep 17 00:00:00 2001 From: Simon Legner Date: Mon, 14 Sep 2026 09:14:57 +0200 Subject: [PATCH 11/11] Announce the export and import status The container is filled dynamically, so it has to be a live region for screen readers to pick the messages up. --- assets/javascripts/templates/pages/offline_tmpl.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/javascripts/templates/pages/offline_tmpl.js b/assets/javascripts/templates/pages/offline_tmpl.js index fd578723f3..2f6f9e7a2d 100644 --- a/assets/javascripts/templates/pages/offline_tmpl.js +++ b/assets/javascripts/templates/pages/offline_tmpl.js @@ -23,7 +23,7 @@ app.templates.offlinePage = (docs, hasPersistence, isPersistent) => `\ ${docs} -

+
${offlinePersistenceNote(hasPersistence, isPersistent)}