Skip to content

Commit 8ebe5bb

Browse files
committed
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
1 parent 12d556b commit 8ebe5bb

4 files changed

Lines changed: 337 additions & 4 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Exports the offline data (the pages stored in IndexedDB and the index files
2+
// cached in localStorage) to a JSON file, and imports it back — either to
3+
// restore a backup after the browser evicted the data, or to move the
4+
// documentations to another computer without downloading them again.
5+
app.OfflineBackup = class OfflineBackup {
6+
static TYPE = "devdocs-offline";
7+
static VERSION = 1;
8+
static MIME_TYPE = "application/json";
9+
10+
filename(docs) {
11+
const date = new Date().toISOString().slice(0, 10);
12+
const name = docs.length === 1 ? docs[0].slug : "offline";
13+
return `devdocs-${name}-${date}.json`;
14+
}
15+
16+
// Calls back with a Blob containing every installed doc among `docs`, and
17+
// the number of docs it holds. Docs that aren't installed are skipped.
18+
export(docs, onProgress, onSuccess, onError) {
19+
const chunks = [
20+
`{"type":"${OfflineBackup.TYPE}","version":${
21+
OfflineBackup.VERSION
22+
},"date":"${new Date().toISOString()}","docs":[`,
23+
];
24+
let count = 0;
25+
let i = 0;
26+
27+
var next = () => {
28+
const doc = docs[i++];
29+
30+
if (!doc) {
31+
if (count === 0) {
32+
onError("empty");
33+
return;
34+
}
35+
chunks.push("]}");
36+
onSuccess(new Blob(chunks, { type: OfflineBackup.MIME_TYPE }), count);
37+
return;
38+
}
39+
40+
onProgress(doc, i, docs.length);
41+
app.db.dump(doc, (result) => {
42+
if (result) {
43+
// Serialize each doc on its own instead of building one big object,
44+
// to avoid holding the whole backup in memory twice.
45+
chunks.push(
46+
(count++ === 0 ? "" : ",") +
47+
JSON.stringify(this.serializeDoc(doc, result)),
48+
);
49+
}
50+
setTimeout(next, 0);
51+
});
52+
};
53+
54+
next();
55+
}
56+
57+
serializeDoc(doc, result) {
58+
const entry = { slug: doc.slug, mtime: result.mtime, db: result.data };
59+
const index = app.localStorage.get(doc.slug);
60+
// Ship the index file too, so that the doc can be used on a computer that
61+
// never downloaded it (the app falls back to the network otherwise).
62+
if (index && index[0] === result.mtime) {
63+
entry.index = index[1];
64+
}
65+
return entry;
66+
}
67+
68+
import(file, onProgress, onSuccess, onError) {
69+
if (!file || (file.type && file.type !== OfflineBackup.MIME_TYPE)) {
70+
onError("invalid");
71+
return;
72+
}
73+
74+
const reader = new FileReader();
75+
reader.onloadend = () => {
76+
const data = (() => {
77+
try {
78+
return JSON.parse(reader.result);
79+
} catch (error) {}
80+
})();
81+
82+
if (!data || data.type !== OfflineBackup.TYPE || !Array.isArray(data.docs)) {
83+
onError("invalid");
84+
return;
85+
}
86+
if (data.version > OfflineBackup.VERSION) {
87+
onError("version");
88+
return;
89+
}
90+
91+
this.importDocs(data.docs, onProgress, onSuccess, onError);
92+
};
93+
reader.onerror = () => onError("invalid");
94+
reader.readAsText(file);
95+
}
96+
97+
importDocs(entries, onProgress, onSuccess, onError) {
98+
const queue = [];
99+
const skipped = [];
100+
101+
for (var entry of entries) {
102+
var doc = entry?.db && this.findDoc(entry.slug);
103+
if (doc) {
104+
queue.push([doc, entry]);
105+
} else {
106+
skipped.push(entry?.slug || "?");
107+
}
108+
}
109+
110+
if (queue.length === 0) {
111+
onError("unknown", skipped);
112+
return;
113+
}
114+
115+
const enabled = this.enableDocs(queue.map(([doc]) => doc));
116+
const total = queue.length;
117+
const imported = [];
118+
const failed = [];
119+
let i = 0;
120+
121+
var next = () => {
122+
const item = queue[i++];
123+
124+
if (!item) {
125+
onSuccess({ docs: imported, skipped, failed, enabled });
126+
return;
127+
}
128+
129+
const [doc, entry] = item;
130+
const mtime = entry.mtime || doc.mtime;
131+
onProgress(doc, i, total);
132+
133+
if (entry.index) {
134+
// Keyed by the backup's mtime so that Doc#_getCache discards it when
135+
// the documentation has been updated since the backup was made.
136+
app.localStorage.set(doc.slug, [mtime, entry.index]);
137+
}
138+
139+
app.db.store(
140+
doc,
141+
entry.db,
142+
mtime,
143+
() => {
144+
imported.push(doc);
145+
setTimeout(next, 0);
146+
},
147+
() => {
148+
failed.push(doc.slug);
149+
setTimeout(next, 0);
150+
},
151+
);
152+
};
153+
154+
next();
155+
}
156+
157+
findDoc(slug) {
158+
return (
159+
app.docs.findBy("slug", slug) || app.disabledDocs.findBy("slug", slug)
160+
);
161+
}
162+
163+
// Enabling the docs up-front is what makes their object stores exist: the
164+
// schema bump triggers DB#onUpgradeNeeded, which only creates stores for the
165+
// enabled docs. Returns the number of docs that weren't enabled before.
166+
enableDocs(docs) {
167+
let enabled = 0;
168+
169+
for (var doc of docs) {
170+
if (app.docs.contains(doc)) {
171+
continue;
172+
}
173+
app.disabledDocs.remove(doc);
174+
app.docs.add(doc);
175+
enabled += 1;
176+
}
177+
178+
if (enabled > 0) {
179+
app.docs.sort();
180+
app.saveDocs();
181+
}
182+
183+
return enabled;
184+
}
185+
};

‎assets/javascripts/templates/pages/offline_tmpl.js‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ app.templates.offlinePage = (docs, hasPersistence, isPersistent) => `\
88
}>Install updates automatically
99
</label>
1010
<div class="_docs-links">
11-
<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>
11+
<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>
1212
</div>
1313
</div>
1414
@@ -23,6 +23,7 @@ app.templates.offlinePage = (docs, hasPersistence, isPersistent) => `\
2323
${docs}
2424
</table>
2525
</div>
26+
<div id="_offline-backup-status"></div>
2627
<div id="_offline-persistence-note">
2728
${offlinePersistenceNote(hasPersistence, isPersistent)}
2829
</div>
@@ -33,6 +34,8 @@ app.templates.offlinePage = (docs, hasPersistence, isPersistent) => `\
3334
The app also uses <a href="https://devdocs.io/dom/service_worker_api/using_service_workers">Service Workers</a> and <a href="https://devdocs.io/dom/web_storage_api">localStorage</a> to cache the assets and index files.
3435
<dt>Can I close the tab/browser?
3536
<dd>${canICloseTheTab()}
37+
<dt>How do I move the documentations to another computer?
38+
<dd>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.
3639
<dt>What if I don't update a documentation?
3740
<dd>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.
3841
<dt>I found a bug, where do I report it?
@@ -44,6 +47,46 @@ app.templates.offlinePage = (docs, hasPersistence, isPersistent) => `\
4447
</dl>\
4548
`;
4649

50+
app.templates.backupProgress = (action, doc, i, total) =>
51+
`${action} ${doc.fullName}\u2026 (${i}/${total})`;
52+
53+
app.templates.backupExported = (count) =>
54+
`Exported ${count} ${pluralizeDocs(count)}.`;
55+
56+
app.templates.backupImported = function (result) {
57+
let html = `<strong>Imported ${result.docs.length} ${pluralizeDocs(
58+
result.docs.length
59+
)}.</strong>`;
60+
61+
if (result.failed.length > 0) {
62+
html += ` Couldn't be stored: ${result.failed.join(", ")}.`;
63+
}
64+
if (result.skipped.length > 0) {
65+
html += ` Not available anymore: ${result.skipped.join(", ")}.`;
66+
}
67+
if (result.enabled > 0) {
68+
html += " Reloading\u2026";
69+
}
70+
71+
return html;
72+
};
73+
74+
app.templates.backupError = function (reason) {
75+
switch (reason) {
76+
case "empty":
77+
return "<strong>No documentation is installed.</strong> Install one before exporting.";
78+
case "unknown":
79+
return "<strong>Nothing to import.</strong> This file doesn't contain any documentation that DevDocs still offers.";
80+
case "version":
81+
return "<strong>This file was exported by a newer version of DevDocs.</strong> Reload the app and try again.";
82+
default:
83+
return "<strong>The file you selected is invalid.</strong> Only files exported from this page can be imported.";
84+
}
85+
};
86+
87+
var pluralizeDocs = (count) =>
88+
count === 1 ? "documentation" : "documentations";
89+
4790
app.templates.persistenceError = function (exception) {
4891
const reason = exception
4992
? `<code class="_label">${exception.name}: ${exception.message}</code>`
@@ -106,11 +149,11 @@ app.templates.offlineDoc = function (doc, status) {
106149
: outdated
107150
? `\
108151
<td><strong>Outdated</strong></td>
109-
<td><button type="button" class="_btn-link _bold" data-action="update">Update</button> - <button type="button" class="_btn-link" data-action="uninstall">Uninstall</button></td>\
152+
<td><button type="button" class="_btn-link _bold" data-action="update">Update</button> &bull; <button type="button" class="_btn-link" data-action="uninstall">Uninstall</button> &bull; <button type="button" class="_btn-link" data-action="export">Export</button></td>\
110153
`
111154
: `\
112155
<td>Up&#8209;to&#8209;date</td>
113-
<td><button type="button" class="_btn-link" data-action="uninstall">Uninstall</button></td>\
156+
<td><button type="button" class="_btn-link" data-action="uninstall">Uninstall</button> &bull; <button type="button" class="_btn-link" data-action="export">Export</button></td>\
114157
`;
115158

116159
return html + "</tr>";

‎assets/javascripts/views/content/offline_page.js‎

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ app.views.OfflinePage = class OfflinePage extends app.View {
7777
onClick(event) {
7878
let el = $.eventTarget(event);
7979
let action = el.getAttribute("data-action");
80-
if (action) {
80+
if (action === "export") {
81+
this.exportDoc(this.docByEl(el), el);
82+
} else if (action) {
8183
const doc = this.docByEl(el);
8284
if (action === "update") {
8385
action = "install";
@@ -102,6 +104,8 @@ app.views.OfflinePage = class OfflinePage extends app.View {
102104
}
103105
} else if (el.hasAttribute("data-enable-persistence")) {
104106
this.requestPersistence();
107+
} else if (el.hasAttribute("data-export-docs")) {
108+
this.exportDocs(app.docs.all());
105109
}
106110
}
107111

@@ -149,6 +153,104 @@ app.views.OfflinePage = class OfflinePage extends app.View {
149153
onChange(event) {
150154
if (event.target.name === "autoUpdate") {
151155
app.settings.set("manualUpdate", !event.target.checked);
156+
} else if (event.target.name === "importDocs") {
157+
this.importDocs(event.target);
158+
}
159+
}
160+
161+
backup() {
162+
return this._backup || (this._backup = new app.OfflineBackup());
163+
}
164+
165+
// Exports `docs` into a single file. Returns false when another backup is
166+
// already running, in which case `onDone` is never called.
167+
exportDocs(docs, onDone) {
168+
if (this.backingUp) {
169+
return false;
170+
}
171+
this.backingUp = true;
172+
const backup = this.backup();
173+
174+
const done = (html, isError, success) => {
175+
this.backingUp = false;
176+
if (!this.activated) {
177+
return;
178+
}
179+
this.setBackupStatus(html, isError);
180+
if (onDone) {
181+
onDone(success);
182+
}
183+
};
184+
185+
backup.export(
186+
docs,
187+
(doc, i, total) =>
188+
this.setBackupStatus(
189+
this.tmpl("backupProgress", "Exporting", doc, i, total),
190+
),
191+
(blob, count) => {
192+
$.download(blob, backup.filename(docs));
193+
done(this.tmpl("backupExported", count), false, true);
194+
},
195+
() => done(this.tmpl("backupError", "empty"), true, false),
196+
);
197+
198+
return true;
199+
}
200+
201+
exportDoc(doc, el) {
202+
const started = this.exportDocs([doc], (success) =>
203+
success ? this.onInstallSuccess(doc) : this.onInstallError(doc),
204+
);
205+
if (started) {
206+
el.parentNode.innerHTML = "Exporting\u2026";
207+
}
208+
}
209+
210+
importDocs(input) {
211+
if (this.backingUp) {
212+
return;
213+
}
214+
this.backingUp = true;
215+
const file = input.files[0];
216+
input.value = ""; // so that picking the same file again fires a change event
217+
218+
this.backup().import(
219+
file,
220+
(doc, i, total) =>
221+
this.setBackupStatus(
222+
this.tmpl("backupProgress", "Importing", doc, i, total),
223+
),
224+
(result) => {
225+
this.backingUp = false;
226+
if (!this.activated) {
227+
return;
228+
}
229+
this.setBackupStatus(
230+
this.tmpl("backupImported", result),
231+
result.failed.length > 0,
232+
);
233+
// Newly enabled docs have no index in memory; reboot to load them.
234+
// Otherwise just refresh the rows that changed, to keep the message.
235+
if (result.enabled > 0) {
236+
this.delay(() => app.reboot(), 2000);
237+
} else {
238+
for (var doc of result.docs) {
239+
this.onInstallSuccess(doc);
240+
}
241+
}
242+
},
243+
(reason) => {
244+
this.backingUp = false;
245+
this.setBackupStatus(this.tmpl("backupError", reason), true);
246+
},
247+
);
248+
}
249+
250+
setBackupStatus(html, isError) {
251+
const el = this.find("#_offline-backup-status");
252+
if (el) {
253+
el.innerHTML = `<p class="_note${isError ? " _note-red" : ""}">${html}`;
152254
}
153255
}
154256

0 commit comments

Comments
 (0)