Add export and import of offline documentation - #2733
Merged
Merged
Conversation
Also revoke the object URL once the browser has picked up the download, which the settings export never did.
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.
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
There was a problem hiding this comment.
🟡 Changes recommended
Import validation, state consistency, markup escaping, and accessibility issues must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds offline documentation backup and restoration across browsers, including exported pages, indexes, and installation timestamps.
Changes:
- Adds JSON export/import for individual or all installed documentation.
- Extends IndexedDB APIs to preserve installation timestamps and dump stored pages.
- Adds backup controls, progress feedback, styling, and shared download handling.
File summaries
| File | Description |
|---|---|
assets/stylesheets/components/_content.scss |
Styles backup controls and file input. |
assets/javascripts/views/content/settings_page.js |
Uses the shared download helper. |
assets/javascripts/views/content/offline_page.js |
Coordinates export/import UI and status updates. |
assets/javascripts/templates/pages/offline_tmpl.js |
Adds backup controls, messages, and documentation. |
assets/javascripts/models/doc.js |
Passes installation timestamps when storing docs. |
assets/javascripts/lib/util.js |
Adds a reusable browser download helper. |
assets/javascripts/app/offline_backup.js |
Implements backup serialization and restoration. |
assets/javascripts/app/db.js |
Supports timestamped writes and database dumps. |
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 8
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+101
to
+106
| for (var entry of entries) { | ||
| var doc = entry?.db && this.findDoc(entry.slug); | ||
| if (doc) { | ||
| queue.push([doc, entry]); | ||
| } else { | ||
| skipped.push(entry?.slug || "?"); |
| html += ` Couldn't be stored: ${result.failed.join(", ")}.`; | ||
| } | ||
| if (result.skipped.length > 0) { | ||
| html += ` Not available anymore: ${result.skipped.join(", ")}.`; |
Comment on lines
+133
to
+151
| 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); | ||
| }, | ||
| ); |
Comment on lines
+210
to
+216
| 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 |
Comment on lines
+224
to
+242
| (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); | ||
| } | ||
| } | ||
| }, |
| </label> | ||
| <div class="_docs-links"> | ||
| <button type="button" class="_btn-link" data-action-all="install">Install all</button><button type="button" class="_btn-link" data-action-all="update"><strong>Update all</strong></button><button type="button" class="_btn-link" data-action-all="uninstall">Uninstall all</button> | ||
| <button type="button" class="_btn-link" data-action-all="install" title="Download every enabled documentation for offline use">Install all</button><button type="button" class="_btn-link" data-action-all="update" title="Download the current version of every outdated documentation"><strong>Update all</strong></button><button type="button" class="_btn-link" data-action-all="uninstall" title="Delete the offline data of every installed documentation">Uninstall all</button><button type="button" class="_btn-link _show" data-export-docs title="Save the installed documentations to a file, to restore them later or on another computer">Export all</button><label class="_btn-link _file-btn _show" title="Restore documentations from a previously exported file">Import<input type="file" name="importDocs" accept="application/json,.json"></label> |
| ${docs} | ||
| </table> | ||
| </div> | ||
| <div id="_offline-backup-status"></div> |
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.
They come from the imported file and end up in the page through innerHTML.
loadend also fires after a failed read, which called back twice because onerror reports the failure already.
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.
The same file stayed selected, so picking it again once the running operation finished didn't fire a change event.
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.
visibility: hidden took it out of the focus order, leaving the Import and settings import buttons unusable with a keyboard.
The container is filled dynamically, so it has to be a live region for screen readers to pick the messages up.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #336.
The offline page can now save installed documentations to a JSON file and restore them later or on another computer, without downloading them again.
UI
titledescribing what they do.File format
{ "type": "devdocs-offline", "version": 1, "date": "2026-09-14T…", "docs": [ { "slug": "bazel~9", "mtime": 1789304775, // the mtime the doc was installed with "db": { "index": "<html>…", … }, // the pages stored in IndexedDB "index": { "entries": […], "types": […] } // index.json, when cached } ] }The index file is included so that a documentation can be read on a computer that never downloaded it. The mtime is the one the documentation was installed with rather than the current one, so a restored documentation that has been updated in the meantime shows up as outdated instead of pretending to be up-to-date.
Importing enables the documentations that aren't enabled yet — which is also what creates their IndexedDB object stores — and reloads the app so their indexes get loaded. Documentations that DevDocs no longer offers are reported as skipped.
As noted in the issue, the app itself still has to be loaded once while online on the target computer for the service worker to cache it; this is now mentioned in the page's Q&A.
Incidental fixes
DB#storenow handles theNotFoundErrorthatdb.transaction()throws synchronously when the object store doesn't exist yet (it happens when a doc is enabled while the database is being opened). Only the equivalent error event was handled before, so this crashed the import reliably.$.downloadand shared with the settings export, which also gains the missingURL.revokeObjectURL.Testing
Driven in a real browser (Playwright + Chromium) against a local instance: