diff --git a/assets/javascripts/app/app.js b/assets/javascripts/app/app.js index 3ced2c403c..f05b963dd5 100644 --- a/assets/javascripts/app/app.js +++ b/assets/javascripts/app/app.js @@ -117,17 +117,18 @@ class App extends Events { delete this.DOC; } - bootAll() { + async bootAll() { const docs = this.settings.getDocs(); for (var doc of this.DOCS) { (docs.includes(doc.slug) ? this.docs : this.disabledDocs).add(doc); } + delete this.DOCS; this.migrateDocs(); + await this.migrateToLatestVersions(); this.docs.load(this.start.bind(this), this.onBootError.bind(this), { readCache: true, writeCache: true, }); - delete this.DOCS; } start() { @@ -190,6 +191,86 @@ class App extends Events { } } + // With the "latest version" preference enabled, replace the enabled docs for + // which a newer version is available with that version. + async migrateToLatestVersions() { + if (!this.settings.get("autoLatestVersion")) { + return; + } + + const allDocs = this.docs.all().concat(this.disabledDocs.all()); + // The same version can supersede several enabled docs, so it's only loaded + // once, e.g. when both CMake 3.9 and CMake 3.10 are enabled. + const migrations = new Map(); + + for (const outdated of this.docs.all()) { + const latest = outdated.findLatestVersion(allDocs); + if (latest === outdated) { + continue; + } + if (!migrations.has(latest)) { + migrations.set(latest, []); + } + migrations.get(latest).push(outdated); + } + + const loaded = await this.loadLatestVersions([...migrations.keys()]); + let needsSaving; + + for (const [latest, outdatedDocs] of migrations) { + if (!loaded.has(latest)) { + continue; + } + for (const outdated of outdatedDocs) { + this.docs.remove(outdated); + this.disabledDocs.add(outdated); + } + if (!this.docs.contains(latest)) { + this.disabledDocs.remove(latest); + this.docs.add(latest); + } + needsSaving = true; + } + + if (needsSaving) { + this.docs.sort(); + this.saveDocs(); + } + } + + // Saving drops the offline data of the docs that are disabled, so the index + // of their latest version has to load before they are replaced. Loads no + // more docs at once than Docs#load does. + async loadLatestVersions(docs) { + const loaded = new Set(); + let i = 0; + + const next = async () => { + while (i < docs.length) { + const doc = docs[i++]; + const success = await new Promise((resolve) => + doc.load( + () => resolve(true), + () => resolve(false), + { readCache: true, writeCache: true }, + ), + ); + if (success) { + loaded.add(doc); + } + } + }; + + await Promise.all( + Array.from( + { length: Math.min(docs.length, app.collections.Docs.CONCURRENCY) }, + next, + ), + ); + + return loaded; + } + enableDoc(doc, _onSuccess, onError) { if (this.docs.contains(doc)) { return; diff --git a/assets/javascripts/app/settings.js b/assets/javascripts/app/settings.js index 617830ecc9..113005a65d 100644 --- a/assets/javascripts/app/settings.js +++ b/assets/javascripts/app/settings.js @@ -14,6 +14,7 @@ app.Settings = class Settings { "tips", "noAutofocus", "autoInstall", + "autoLatestVersion", "spaceScroll", "spaceTimeout", "noDocSpecificIcon", @@ -40,6 +41,7 @@ app.Settings = class Settings { spaceScroll: 1, spaceTimeout: 0.5, noDocSpecificIcon: false, + autoLatestVersion: false, }; constructor() { diff --git a/assets/javascripts/models/doc.js b/assets/javascripts/models/doc.js index 0a5c815a85..990c4046df 100644 --- a/assets/javascripts/models/doc.js +++ b/assets/javascripts/models/doc.js @@ -1,6 +1,8 @@ app.models.Doc = class Doc extends app.Model { // Attributes: name, slug, type, version, release, db_size, mtime, links + static NUMBERED_VERSION_RGX = /^\d+(\.\d+)*$/; + constructor() { super(...arguments); this.reset(this); @@ -192,6 +194,53 @@ app.models.Doc = class Doc extends app.Model { ); } + // Whether the doc holds a numbered version of its documentation (e.g. "3.9"), + // as opposed to a variant (e.g. "10 LTS" or "Python"), which can't be + // ordered. An empty version means the doc holds the latest version + // (e.g. `angular`), whereas docs without a version aren't versioned at all. + hasNumberedVersion() { + return ( + this.version === "" || Doc.NUMBERED_VERSION_RGX.test(this.version || "") + ); + } + + // Compares numbered versions (e.g. "3.9" is older than "3.12"). + // An empty version means the latest version and is newer than any other. + isNewerVersionThan(other) { + if (this.version === "" || other.version === "") { + return this.version === "" && other.version !== ""; + } + const version = this.version.split("."); + const otherVersion = other.version.split("."); + for (let i = 0; i < Math.max(version.length, otherVersion.length); i++) { + const diff = + (parseInt(version[i], 10) || 0) - (parseInt(otherVersion[i], 10) || 0); + if (diff !== 0) { + return diff > 0; + } + } + return false; + } + + // Returns the doc holding the latest version of the same documentation among + // `docs`, or the doc itself when there is none. + findLatestVersion(docs) { + let latest = this; + if (!this.hasNumberedVersion()) { + return latest; + } + for (var doc of docs) { + if ( + doc.name === this.name && + doc.hasNumberedVersion() && + doc.isNewerVersionThan(latest) + ) { + latest = doc; + } + } + return latest; + } + isOutdated(status) { if (!status) { return false; diff --git a/assets/javascripts/templates/pages/settings_tmpl.js b/assets/javascripts/templates/pages/settings_tmpl.js index cfd30de1b2..f0f84bfa19 100644 --- a/assets/javascripts/templates/pages/settings_tmpl.js +++ b/assets/javascripts/templates/pages/settings_tmpl.js @@ -60,6 +60,12 @@ app.templates.settingsPage = (settings) => `\ }>Automatically download documentation for offline use Only enable this when bandwidth isn't a concern to you. +