diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..1a6bd45
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+package-lock.json binary
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9ed75c8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,104 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# TypeScript v1 declaration files
+typings/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+.env.test
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+
+# Next.js build output
+.next
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and *not* Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
diff --git a/Gruntfile.js b/Gruntfile.js
new file mode 100644
index 0000000..f042e53
--- /dev/null
+++ b/Gruntfile.js
@@ -0,0 +1,97 @@
+const path = require('path');
+
+module.exports = grunt => {
+ grunt.initConfig({
+ pkg: grunt.file.readJSON("package.json"),
+ clean: {
+ default: ["build/"]
+ },
+ copy: {
+ html: {
+ files: [{expand: true, cwd: "src/main/", src: "**/*.html", dest: "build/"}]
+ },
+ css: {
+ files: [{expand: true, cwd: "src/main/", src: "**/*.css", dest: "build/"}]
+ }
+ },
+ replace: {
+ dev: {
+ src: ["./build/*.html", "./build/*.js"],
+ replacements: [
+ {
+ from: "%%VERSION_NUMBER%%",
+ to: "<%= pkg.version %>+" + new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "")
+ }
+ ],
+ overwrite: true
+ },
+ deploy: {
+ src: ["./build/*.html", "./build/*.js"],
+ replacements: [
+ {
+ from: "%%VERSION_NUMBER%%",
+ to: "<%= pkg.version %>"
+ }
+ ],
+ overwrite: true
+ }
+ },
+ webpack: {
+ options: {
+ entry: "./src/main/js/main.js",
+ module: {
+ rules: [
+ {
+ test: /\.js$/,
+ exclude: /node_modules/,
+ },
+ ],
+ },
+ resolve: {
+ extensions: [".js"],
+ },
+ output: {
+ filename: "bundle.js",
+ path: path.resolve(__dirname, "build/"),
+ }
+ },
+ dev: {
+ mode: "development",
+ devtool: "inline-source-map"
+ },
+ deploy: {
+ mode: "production"
+ }
+ }
+ });
+
+ grunt.loadNpmTasks("grunt-contrib-clean");
+ grunt.loadNpmTasks("grunt-contrib-copy");
+ grunt.loadNpmTasks("grunt-text-replace");
+ grunt.loadNpmTasks("grunt-webpack");
+
+ grunt.registerTask("dev", [
+ // Pre
+ "clean",
+ // Copy files
+ "copy:html",
+ "copy:css",
+ // Compile
+ "webpack:dev",
+ // Post
+ "replace:dev"
+ ]);
+ grunt.registerTask("deploy", [
+ // Pre
+ "clean",
+ // Copy files
+ "copy:html",
+ "copy:css",
+ // Compile JS
+ "webpack:deploy",
+ // Post
+ "replace:deploy"
+ ]);
+
+ grunt.registerTask("default", ["dev"]);
+};
diff --git a/index.html b/index.html
deleted file mode 100644
index 5a78104..0000000
--- a/index.html
+++ /dev/null
@@ -1,999 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Interlanguage Checker | FWDekker
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..bf9a586
Binary files /dev/null and b/package-lock.json differ
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0436047
--- /dev/null
+++ b/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "interlanguage-checker",
+ "version": "1.2.3",
+ "description": "Check the consistency of MediaWiki interlanguage links in a simple overview.",
+ "author": "Felix W. Dekker",
+ "browser": "bundle.js",
+ "repository": {
+ "type": "git",
+ "url": "git@git.fwdekker.com:FWDekker/interlanguage-checker.git"
+ },
+ "private": true,
+ "scripts": {
+ "clean": "grunt clean",
+ "dev": "grunt dev",
+ "deploy": "grunt deploy"
+ },
+ "dependencies": {
+ "fetch-jsonp": "^1.1.3"
+ },
+ "devDependencies": {
+ "grunt": "^1.1.0",
+ "grunt-cli": "^1.3.2",
+ "grunt-contrib-clean": "^2.0.0",
+ "grunt-contrib-copy": "^1.0.0",
+ "grunt-text-replace": "^0.4.0",
+ "grunt-webpack": "^3.1.3",
+ "webpack": "^4.42.1",
+ "webpack-cli": "^3.3.11"
+ }
+}
diff --git a/src/main/css/main.css b/src/main/css/main.css
new file mode 100644
index 0000000..aa203d0
--- /dev/null
+++ b/src/main/css/main.css
@@ -0,0 +1,126 @@
+:root {
+ --error-color: red;
+ --success-color: green;
+}
+
+
+/***
+ * Table
+ **/
+#networkTableForm {
+ /* Center table */
+ width: 100%;
+}
+
+#networkTable {
+ /* Center table */
+ margin: 0 auto;
+
+ /* Make table as small as possible */
+ width: auto;
+ table-layout: fixed;
+}
+
+
+/* Text alignment */
+#networkTable th.sourceLabel, #networkTable td.sourceLabel {
+ text-align: right;
+}
+
+#networkTable th:not(.sourceLabel), #networkTable td:not(.sourceLabel) {
+ text-align: center;
+}
+
+
+/* Borders */
+#networkTable th, #networkTable td {
+ border-right: 1px solid var(--kpxc-table-border-color);
+}
+
+#networkTable tr:last-child {
+ border-bottom: none;
+}
+
+#networkTable th:first-child, #networkTable td:first-child,
+#networkTable th:last-child, #networkTable td:last-child {
+ /* Undo Milligram padding because it looks bad with column borders */
+ padding-left: 1.5rem;
+ padding-right: 1.5rem;
+}
+
+
+/* Colors */
+#networkTable tbody tr:nth-child(odd) {
+ background-color: var(--kpxc-table-hover-color);
+}
+
+#networkTable td.correct {
+ color: var(--success-color);
+}
+
+#networkTable td.incorrect {
+ color: var(--error-color);
+}
+
+#networkTable th a i {
+ font-size: 0.9em;
+ font-weight: normal;
+}
+
+
+/***
+ * Loading icon
+ *
+ * From https://loading.io/css/.
+ **/
+.lds-dual-ring {
+ display: inline-block;
+ width: 1em;
+ height: 1em;
+}
+
+.lds-dual-ring:after {
+ content: " ";
+ display: block;
+ width: 1em;
+ height: 1em;
+ border-radius: 50%;
+ border: 1px solid;
+ border-color: #000 transparent;
+ animation: lds-dual-ring 1.2s linear infinite;
+}
+
+@keyframes lds-dual-ring {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+
+/***
+ * Messages, errors, etc.
+ **/
+#messages {
+ width: 100%;
+ text-align: center;
+}
+
+.messageInner {
+ display: inline-block;
+}
+
+#errorMessage {
+ color: var(--error-color);
+}
+
+input[data-entered=true]:invalid {
+ border-color: var(--error-color);
+ color: var(--error-color);
+}
+
+.redLink a {
+ color: #ba0000;
+}
diff --git a/src/main/index.html b/src/main/index.html
new file mode 100644
index 0000000..2250c80
--- /dev/null
+++ b/src/main/index.html
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+ Interlanguage Checker | FWDekker
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/js/main.js b/src/main/js/main.js
new file mode 100644
index 0000000..116918e
--- /dev/null
+++ b/src/main/js/main.js
@@ -0,0 +1,780 @@
+import fetchJsonp from "fetch-jsonp";
+
+
+/**
+ * A data class for combining a language and page title to identify a page.
+ *
+ * @property lang {string} the language of the wiki this page is of
+ * @property title {string} the title of the page
+ */
+class InterlangLink {
+ /**
+ * Constructs a new `InterlangLink`.
+ *
+ * @param lang {string} the language of the wiki this page is of
+ * @param title {string} the title of the page
+ */
+ constructor(lang, title) {
+ this.lang = lang;
+ this.title = title;
+ }
+
+
+ /**
+ * Returns `true` if and only if the given object equals this `InterlangLink`.
+ *
+ * @param other {*} the object to compare to this `InterlangLink`
+ * @returns {boolean} `true` if and only if the given object equals this `InterlangLink`
+ */
+ equals(other) {
+ return other instanceof InterlangLink && this.lang === other.lang && this.title === other.title;
+ }
+
+ /**
+ * Converts this `InterlangLink` to a string.
+ *
+ * @returns {string} the string representation of this `InterlangLink`
+ */
+ toString() {
+ return `${this.lang}:${this.title}`;
+ }
+
+ /**
+ * Converts a string obtained from the `#toString` method back into an `InterlangLink`.
+ *
+ * @param string {string} the string to convert to an `InterlangLink`
+ * @returns {InterlangLink} the `InterlangLink` obtained from the given string
+ */
+ static fromString(string) {
+ const parts = string.split(/:(.+)/);
+ return new InterlangLink(parts[0], parts[1]);
+ }
+}
+
+/**
+ * A map of interwiki links.
+ *
+ * @property map {Object.} maps interwiki abbreviations to URLs
+ */
+class InterwikiMap {
+ /**
+ * Constructs a new interwiki map.
+ *
+ * @param map {Object.} the mapping from interwiki abbreviations to URLs to store in this map
+ */
+ constructor(map) {
+ this.map = Object.assign({}, map);
+ }
+
+
+ /**
+ * Returns the URL for the given abbreviation, or `undefined` if the abbreviation could not be found.
+ *
+ * @param abbr {string} the abbreviation to return the URL of
+ * @returns {string} the URL for the given abbreviation, or `undefined` if the abbreviation could not be found
+ */
+ getUrl(abbr) {
+ return this.map[abbr];
+ }
+
+ /**
+ * Returns `true` if and only if this map has a URL for the given abbreviation.
+ *
+ * @param abbr {string} the abbreviation to check for
+ * @returns {boolean} `true` if and only if this map has a URL for the given abbreviation
+ */
+ hasUrl(abbr) {
+ return this.map[abbr] !== undefined;
+ }
+
+ /**
+ * Returns a new `InterwikiMap` with all entries from both this map and the given map.
+ *
+ * @param other {InterwikiMap} the map to merge with
+ * @return a new `InterwikiMap` with all entries from both this map and the given map
+ */
+ mergeWith(other) {
+ const conflicts = Object.keys(this.map)
+ .filter(key => other.map.hasOwnProperty(key))
+ .filter(key => this.map[key] !== other.map[key]);
+ if (conflicts.length !== 0)
+ console.warn("iw map merge conflict(s)", conflicts);
+
+ return new InterwikiMap(Object.assign({}, this.map, other.map));
+ }
+}
+
+
+/**
+ * Interacts with the API in an asynchronous manner.
+ *
+ * @property baseUrl {string} the origin of the wiki's API
+ * @property apiPath {string} the path relative to the wiki's API; starts with a `/`
+ */
+class MediaWiki {
+ /**
+ * Constructs a new MediaWiki object.
+ *
+ * @param apiUrl the url to the `api.php` file
+ */
+ constructor(apiUrl) {
+ const urlObj = new URL(apiUrl);
+ this.origin = urlObj.origin;
+ this.apiPath = urlObj.pathname;
+ }
+
+
+ /**
+ * Sends a request to the MediaWiki API and runs the given callback on the response.
+ *
+ * @param params {Object} the parameters to send to the API
+ * @return {Promise} the API's response
+ */
+ request(params) {
+ console.debug(`Requesting from ${this.origin}${this.apiPath} with params`, params);
+ return fetchJsonp(this.origin + this.apiPath + "?format=json&" + new URLSearchParams(params).toString())
+ .then(it => it.json())
+ .catch(() => {
+ throw new Error("Could not to connect to API. Is the URL correct?")
+ });
+ }
+
+ /**
+ * Requests all language links on the given pages.
+ *
+ * @param pages {string[]} an array of pages to return links of
+ * @param [limit] {number|"max"} the maximum number of links to returns over all pages, between 1 and 5000
+ * (inclusive); or "max" for the maximum
+ * @return {Promise>} the language links per page, which are
+ * `undefined` if the page could not be found
+ */
+ getLangLinks(pages, limit) {
+ if (limit === undefined) limit = "max";
+
+ return this
+ .request({action: "query", prop: "langlinks", titles: pages.join("|"), lllimit: limit})
+ .then(response => response.query.pages)
+ .then(pages =>
+ Object.keys(pages).reduce((links, key) => {
+ let langlinks = undefined;
+ if (key >= 0)
+ langlinks = (pages[key].langlinks || []).map(it => new InterlangLink(it.lang, it["*"]));
+
+ links[pages[key].title] = langlinks;
+ return links;
+ }, {})
+ );
+ }
+
+ /**
+ * Returns this wiki's general information.
+ *
+ * @return {Object} this wiki's general information
+ */
+ getGeneralInfo() {
+ return this.request({action: "query", meta: "siteinfo", siprop: "general"})
+ .then(response => response.query.general);
+ }
+
+ /**
+ * Requests this wiki's interwiki map.
+ *
+ * @return {Promise} this wiki's interwiki map
+ */
+ getIwMap() {
+ return this
+ .request({action: "query", meta: "siteinfo", siprop: "interwikimap"})
+ .then(response =>
+ response.query.interwikimap.reduce((map, mapping) => {
+ map[mapping["prefix"]] = mapping["url"];
+ return map;
+ }, {})
+ )
+ .then(map => new InterwikiMap(map));
+ }
+}
+
+/**
+ * Manages a `MediaWiki` instance for different languages, caching retrieved information for re-use.
+ *
+ * @property mws {Object.} the cached `MediaWiki` instances
+ * @property articlePath {string} the path to articles, where `$1` indicates the article name
+ * @property apiPath {string} the path to `api.php`
+ * @property baseLang {string} the language of the base `MediaWiki`, where the exploration starts
+ */
+class MediaWikiManager {
+ /**
+ * Constructs a new `MediaWikiManager`.
+ *
+ * The `#init` method **must** be called before invoking any other function. Behavior is undefined otherwise.
+ */
+ constructor() {
+ this.mws = {};
+ this._iwMap = new InterwikiMap({});
+ }
+
+ /**
+ * Initializes this `MediaWikiManager`.
+ *
+ * @param baseMw {MediaWiki} the `MediaWiki` that is used as a starting point
+ * @return {MediaWikiManager} this `MediaWikiManager`
+ */
+ async init(baseMw) {
+ const general = await baseMw.getGeneralInfo();
+
+ this.articlePath = "" + general.articlepath;
+ this.apiPath = baseMw.apiPath;
+ this.baseLang = general.lang;
+
+ this.mws[general.lang] = baseMw;
+ await this._importIwMap(baseMw);
+
+ return this;
+ }
+
+
+ /**
+ * Returns the `MediaWiki` for the given language, creating it if necessary, or `undefined` if it it could not
+ * be created.
+ *
+ * @param lang {string} the language of the `MediaWiki` to return
+ * @returns {MediaWiki} the `MediaWiki` for the given language, or `undefined` if it could not be created
+ */
+ async getMw(lang) {
+ if (this.hasMw(lang))
+ return this.mws[lang];
+
+ if (!this._iwMap.hasUrl(lang))
+ return undefined;
+
+ const url = this._iwMap.getUrl(lang);
+ this.mws[lang] = new MediaWiki(url.slice(0, -this.articlePath.length) + this.apiPath);
+ await this._importIwMap(this.mws[lang]);
+
+ return this.mws[lang];
+ }
+
+ /**
+ * Returns `true` if and only if this manager has a `MediaWiki` for the given language.
+ *
+ * @param lang {string} the language of the `MediaWiki` to check presence of
+ * @returns {boolean} `true` if and only if this manager has a `MediaWiki` for the given language
+ */
+ hasMw(lang) {
+ return this.mws[lang] !== undefined;
+ }
+
+ /**
+ * Imports the interwiki map from the given wiki, fetching it from the server and merging it into this manager's
+ * main interwiki map.
+ *
+ * @param mw {MediaWiki} the `MediaWiki` to import the interwiki map of
+ */
+ async _importIwMap(mw) {
+ this._iwMap = this._iwMap.mergeWith(await mw.getIwMap());
+ }
+
+ /**
+ * Returns the article paths of all `MediaWiki`s known to this manager.
+ *
+ * @returns {Object.} the article paths of all languages known to this manager
+ */
+ getArticlePaths() {
+ return Object.keys(this.mws).reduce((paths, lang) => {
+ paths[lang] = this._iwMap.getUrl(lang).slice(0, -2);
+ return paths;
+ }, {});
+ }
+}
+
+/**
+ * A network of interlanguage links.
+ */
+class InterlangNetwork {
+ /**
+ * Constructs a new `InterlangNetwork`.
+ *
+ * @param mappings {Object.} a mapping from source page to all pages it links to
+ * @param missing {InterlangLink[]} list of articles that do not exist
+ * @param articlePaths = {Object.} the article paths of each language
+ */
+ constructor(mappings, missing, articlePaths) {
+ this._mappings = mappings;
+ this._missing = missing;
+ this._articlePaths = articlePaths;
+ }
+
+
+ /**
+ * Returns an appropriate label for the given link.
+ *
+ * The label itself is an `a` `HTMLElement` linking to the editor for the given link.
+ * The text in the label is just the language of the link if the given link is the only link with that language
+ * in this network, or the entire link otherwise.
+ *
+ * @param linkStr {string} the link to generate a label of
+ * @return {HTMLElement} an appropriate label for the given link
+ */
+ _generateLabel(linkStr) {
+ const link = InterlangLink.fromString(linkStr);
+ const url = this._articlePaths[link.lang] + link.title;
+
+ const span = document.createElement("span");
+ if (this._missing.some(it => it.equals(link)))
+ span.classList.add("redLink");
+
+ const label = document.createElement("a");
+ label.href = url;
+ label.target = "_blank";
+ label.title = linkStr;
+ label.innerText =
+ Object.keys(this._mappings).filter(it => link.lang === InterlangLink.fromString(it).lang).length > 1
+ ? linkStr
+ : link.lang;
+ span.appendChild(label);
+
+ const padding = document.createElement("span");
+ padding.innerHTML = " ";
+ span.appendChild(padding);
+
+ const editIconLink = document.createElement("a");
+ editIconLink.href = url + "?action=edit";
+ editIconLink.target = "_blank";
+ editIconLink.title = "Edit";
+ new FontIcon("pencil").appendTo(editIconLink);
+ span.appendChild(editIconLink);
+
+ span.appendChild(padding.cloneNode(true));
+
+ const copyIconLink = document.createElement("a");
+ copyIconLink.href = "#";
+ copyIconLink.title = "Copy";
+ const copyIcon = new FontIcon("clipboard").appendTo(copyIconLink);
+ copyIconLink.addEventListener("click", () => {
+ navigator.clipboard.writeText(`[[${linkStr}]]`);
+ copyIcon.changeTo("check", 1000);
+ });
+ span.appendChild(copyIconLink);
+
+ return span;
+ }
+
+ /**
+ * Generates the head of the table generated by `#toTable`.
+ *
+ * @return {HTMLElement} the head of the table generated by `#toTable`
+ */
+ _generateTableHead() {
+ const head = document.createElement("thead");
+
+ const topRow = document.createElement("tr");
+ const srcHead = document.createElement("th");
+ srcHead.innerText = "Source";
+ srcHead.rowSpan = 2;
+ srcHead.classList.add("sourceLabel");
+ topRow.appendChild(srcHead);
+
+ const dstHead = document.createElement("th");
+ dstHead.innerText = "Destination";
+ dstHead.colSpan = Object.keys(this._mappings).length;
+ topRow.appendChild(dstHead);
+ head.appendChild(topRow);
+
+ const bottomRow = document.createElement("tr");
+ Object.keys(this._mappings).sort().forEach(key => {
+ const cell = document.createElement("th");
+ cell.appendChild(this._generateLabel(key));
+ bottomRow.appendChild(cell);
+ });
+ head.appendChild(bottomRow);
+
+ return head;
+ }
+
+ /**
+ * Generates the body of the table generated by `#toTable`.
+ *
+ * @return {HTMLElement} the body of the table generated by `#toTable`
+ */
+ _generateTableBody() {
+ const body = document.createElement("tbody");
+
+ Object.keys(this._mappings).sort().forEach(srcKey => {
+ const row = document.createElement("tr");
+
+ const label = document.createElement("th");
+ label.appendChild(this._generateLabel(srcKey));
+ label.classList.add("sourceLabel");
+ row.appendChild(label);
+
+ Object.keys(this._mappings).sort().forEach(dstKey => {
+ const cell = document.createElement("td");
+ if (srcKey !== dstKey) {
+ const isPresent = this._mappings[srcKey].some(it => it.equals(InterlangLink.fromString(dstKey)));
+ cell.innerText = isPresent ? "✓" : "✗";
+ cell.classList.add(isPresent ? "correct" : "incorrect");
+ }
+ row.appendChild(cell);
+ });
+
+ body.appendChild(row);
+ });
+
+ return body;
+ }
+
+ /**
+ * Generates an HTML table describing the interlanguage network.
+ *
+ * @return {HTMLElement} the generated table
+ */
+ toTable() {
+ const table = document.createElement("table");
+ table.appendChild(this._generateTableHead());
+ table.appendChild(this._generateTableBody());
+ return table;
+ }
+
+
+ /**
+ * Discovers the interlanguage network, starting from the given link.
+ *
+ * @param mwm {MediaWikiManager} the manager to use for caching and resolving pages
+ * @param title {string} the title of the page to start traversing at
+ * @param [progressCb] {function(Object[]): void} a function handling progress updates
+ * @returns {Promise} a network of interlanguage links
+ */
+ static async discoverNetwork(mwm, title, progressCb) {
+ const mappings = {};
+ const missing = [];
+
+ const history = [];
+ const queue = [new InterlangLink(mwm.baseLang, title)];
+ while (queue.length > 0) {
+ const next = queue.pop();
+ if (history.some(it => it.equals(next)))
+ continue;
+
+ progressCb("Checking", next);
+ history.push(next);
+ const nextMw = await mwm.getMw(next.lang);
+ await nextMw
+ .getLangLinks([next.title])
+ .then(langlinks => langlinks[next.title])
+ .then(langlinks => {
+ mappings[next.toString()] = [];
+ if (langlinks === undefined) {
+ missing.push(next);
+ return;
+ }
+ if (langlinks.length === 0)
+ return;
+
+ mappings[next.toString()] = langlinks;
+ queue.push(...langlinks);
+ });
+ }
+
+ return new InterlangNetwork(mappings, missing, mwm.getArticlePaths());
+ }
+}
+
+
+/**
+ * Interacts with the DOM to delegate messages to the user.
+ */
+class MessageHandler {
+ /**
+ * Constructs a new `MessageHandler`, inserting relevant new elements into the DOM to interact with.
+ *
+ * @param parent {HTMLElement} the element to insert elements into
+ * @param [id] {string} the id of the div containing the message
+ */
+ constructor(parent, id) {
+ this._mainDiv = document.createElement("div");
+ this._mainDiv.style.display = "none";
+ this._mainDiv.classList.add("messageInner");
+ parent.appendChild(this._mainDiv);
+
+ this._loadingIcon = document.createElement("div");
+ this._loadingIcon.classList.add("lds-dual-ring");
+ this._mainDiv.appendChild(this._loadingIcon);
+
+ this._spacing = document.createElement("span");
+ this._spacing.innerHTML = " ";
+ this._mainDiv.appendChild(this._spacing);
+
+ this._textSpan = document.createElement("span");
+ if (id !== undefined)
+ this._textSpan.id = id;
+ this._mainDiv.appendChild(this._textSpan);
+
+ this._callback = undefined;
+ }
+
+
+ /**
+ * Handles the displaying of the given messages.
+ *
+ * If no messages are given, the current message and the loading icon are hidden. To display an empty message
+ * next to the loading icon, give an empty string.
+ *
+ * @param messages {...*} the messages to display, or no messages if the current message should be hidden
+ * @returns {MessageHandler} this `MessageHandler`
+ */
+ handle(...messages) {
+ if (messages.length === 0) {
+ this._mainDiv.style.display = "none";
+ return this;
+ }
+
+ if (this._callback !== undefined)
+ this._callback(...messages);
+
+ this._textSpan.innerText = messages.join(" ");
+ this._mainDiv.style.display = "initial";
+
+ return this;
+ }
+
+ /**
+ * Calls `#handle` without any arguments.
+ *
+ * @returns {MessageHandler} this `MessageHandler`
+ */
+ clear() {
+ return this.handle();
+ }
+
+ /**
+ * Sets the callback to be executed whenever messages are handler by this handler.
+ *
+ * @param callback {function(*[]): *} the function to execute whenever messages are handled
+ * @returns {MessageHandler} this `MessageHandler`
+ */
+ setCallback(callback) {
+ this._callback = callback;
+ return this;
+ }
+
+ /**
+ * Turns the loading icon on or off.
+ *
+ * @param state {boolean} `true` if and only if the loading icon should be on
+ * @returns {MessageHandler} this `MessageHandler`
+ */
+ toggleLoadingIcon(state) {
+ this._loadingIcon.style.display = state ? undefined : "none";
+ this._spacing.style.display = state ? undefined : "none";
+
+ return this;
+ }
+}
+
+/**
+ * An input that can be validated.
+ *
+ * @property input {HTMLElement} the input that is validatable
+ */
+class ValidatableInput {
+ /**
+ * Constructs a new validatable input.
+ *
+ * @param input {HTMLInputElement} the input that is validatable
+ * @param isValid {function(string): string} returns an empty string if the given input string is valid, and a
+ * string explaining why it is is invalid otherwise
+ */
+ constructor(input, isValid) {
+ this.input = input;
+ this._isValid = isValid;
+ }
+
+
+ /**
+ * Returns the value of the underlying input element.
+ *
+ * @return {string} the value of the underlying input element
+ */
+ getValue() {
+ return this.input.value;
+ }
+
+ /**
+ * Validates the input.
+ *
+ * @return an empty string if the input string is valid, and a string explaining why it is is invalid otherwise
+ */
+ validate() {
+ const validity = this._isValid(this.input.value);
+
+ if (validity.length === 0) this.showSuccess();
+ else this.showError();
+
+ return validity;
+ }
+
+ /**
+ * Marks the input as neither valid nor invalid.
+ */
+ showBlank() {
+ this.input.dataset["entered"] = "false";
+ this.input.setCustomValidity("");
+ }
+
+ /**
+ * Marks the input as invalid and moves focus to it.
+ */
+ showError() {
+ this.input.dataset["entered"] = "true";
+ this.input.setCustomValidity("Incorrect");
+ this.input.focus();
+ }
+
+ /**
+ * Marks the input as valid.
+ */
+ showSuccess() {
+ this.input.dataset["entered"] = "true";
+ this.input.setCustomValidity("");
+ }
+}
+
+
+/**
+ * A font-based icon.
+ */
+class FontIcon {
+ /**
+ * Constructs a new `FontIcon`.
+ *
+ * @param name {string} the name of the icon
+ */
+ constructor(name) {
+ this._node = document.createElement("i");
+ this._node.classList.add("fa", `fa-${name}`);
+
+ this._name = name;
+ }
+
+
+ /**
+ * Appends this icon to the given parent node.
+ *
+ * @param parent {HTMLElement} the node to append the icon to
+ * @return {FontIcon} this `FontIcon`
+ */
+ appendTo(parent) {
+ parent.appendChild(this._node);
+ return this;
+ }
+
+ /**
+ * Temporarily changes this icon to the given name.
+ *
+ * @param name {string} the temporary icon to display
+ * @param time {number} the number of milliseconds to display the other icon
+ */
+ changeTo(name, time) {
+ this._node.classList.remove(`fa-${this._name}`);
+ this._node.classList.add(`fa-${name}`);
+
+ setTimeout(() => {
+ this._node.classList.remove(`fa-${name}`);
+ this._node.classList.add(`fa-${this._name}`);
+ }, time);
+ }
+}
+
+
+// noinspection JSUnresolvedFunction
+doAfterLoad(async () => {
+ const urlInput = new ValidatableInput($("#url"), (value) => {
+ if (value.trim() === "")
+ return "URL must not be empty.";
+
+ try {
+ new URL(value); // Throws exception if invalid
+ return "";
+ } catch (error) {
+ return error.message;
+ }
+ });
+ const pageInput = new ValidatableInput($("#page"), (value) => {
+ return value.trim() === "" ? "Page must not be empty" : "";
+ });
+ const checkButton = $("#check");
+ const messages = $("#messages");
+
+ const progressHandler = new MessageHandler(messages)
+ .setCallback(() => errorHandler.clear())
+ .toggleLoadingIcon(true);
+ const errorHandler = new MessageHandler(messages, "errorMessage")
+ .setCallback((...messages) => {
+ progressHandler.clear();
+ console.error(...messages);
+ })
+ .toggleLoadingIcon(false);
+
+
+ let previousUrl = undefined;
+ let mwm = undefined;
+
+ const submit = async () => {
+ // Clean up
+ urlInput.showBlank();
+ pageInput.showBlank();
+ progressHandler.clear();
+ errorHandler.clear();
+
+ const oldTable = $("#networkTable");
+ if (oldTable !== null)
+ oldTable.parentNode.removeChild(oldTable);
+
+ // Validate
+ const urlValidity = urlInput.validate();
+ if (urlValidity !== "") return errorHandler.handle(urlValidity);
+
+ const pageValidity = pageInput.validate();
+ if (pageValidity !== "") return errorHandler.handle(pageValidity);
+
+ // Initialize
+ if (urlInput.getValue() !== previousUrl) {
+ progressHandler.handle("Initializing", urlInput.getValue());
+
+ try {
+ const mw = new MediaWiki(urlInput.getValue());
+ mwm = await new MediaWikiManager().init(mw);
+ } catch (error) {
+ errorHandler.handle(error);
+ return;
+ }
+
+ previousUrl = urlInput.getValue();
+ }
+
+ // Discover
+ InterlangNetwork
+ .discoverNetwork(mwm, pageInput.getValue(), progressHandler.handle.bind(progressHandler))
+ .then(it => {
+ progressHandler.handle("Creating table");
+
+ const newTable = it.toTable();
+ newTable.id = "networkTable";
+
+ const form = $("#networkTableForm");
+ form.textContent = "";
+ form.appendChild(newTable);
+
+ progressHandler.handle();
+ })
+ .catch(error => errorHandler.handle(error));
+ };
+
+ urlInput.input.addEventListener("keypress", (event) => {
+ if (event.key.toLowerCase() === "enter") submit();
+ });
+ pageInput.input.addEventListener("keypress", (event) => {
+ if (event.key.toLowerCase() === "enter") submit();
+ });
+ checkButton.addEventListener("click", () => submit());
+});