Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 76 additions & 12 deletions assets/javascripts/app/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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);
}
Expand All @@ -219,7 +237,7 @@ app.DB = class DB {
}

store = txn.objectStore("docs");
store.put(doc.mtime, doc.slug);
store.put(mtime, doc.slug);
});
}

Expand Down Expand Up @@ -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) {
Expand Down
207 changes: 207 additions & 0 deletions assets/javascripts/app/offline_backup.js
Original file line number Diff line number Diff line change
@@ -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;
}
};
13 changes: 13 additions & 0 deletions assets/javascripts/lib/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion assets/javascripts/models/doc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading