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 - - - - - - - - - -
- -
-
-

Interlanguage Checker

- -
-

Check the consistency of MediaWiki interlanguage links in a simple overview.

-
-
-
- - - -
- -
-
-
- - - - - -
- -
-
-
-
- - -
-
-
-
-
-
-
-
- - - - -
- - - - - - - - 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 + + + + + + + + +
+ +
+
+

Interlanguage Checker

+ +
+

Check the consistency of MediaWiki interlanguage links in a simple overview.

+
+
+
+ + + +
+ +
+
+
+ + + + + +
+ +
+
+
+
+ + +
+
+
+
+
+
+
+
+ + + + +
+ + + + + + + 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()); +});