Skip to content

Add export and import of offline documentation - #2733

Merged
simon04 merged 11 commits into
mainfrom
offline-export-import
Sep 14, 2026
Merged

simon04 merged 11 commits into
mainfrom
offline-export-import

Conversation

@simon04

@simon04 simon04 commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

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

  • Export in the action column of each installed documentation — saves that one documentation.
  • Export all next to Install all / Update all / Uninstall all — saves every installed documentation into a single file.
  • Import next to it — restores either kind of file.
  • All buttons in that row now have a title describing 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#store now handles the NotFoundError that db.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.
  • The download logic is extracted into $.download and shared with the settings export, which also gains the missing URL.revokeObjectURL.

Testing

Driven in a real browser (Playwright + Chromium) against a local instance:

  • export of a single doc and of all installed docs; the exported file contains the expected pages and index
  • import into a fresh profile: doc gets enabled, pages restored with the original mtime, index cached, app reloads, and a page loads with the network disabled
  • re-import into a profile where the docs are already installed (no reload, rows refresh in place)
  • error paths: invalid file, unknown slug, file from a newer version, export with nothing installed

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
@simon04
simon04 requested a review from a team as a code owner September 14, 2026 06:59
@simon04
simon04 requested a balanced review from Copilot September 14, 2026 06:59
@simon04 simon04 self-assigned this Sep 14, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 thread assets/javascripts/app/offline_backup.js Outdated
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.
@simon04
simon04 merged commit f2b81c1 into main Sep 14, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Export and import offline data

2 participants